Merge upstream/main into feat/variables-duplicate-group-2084

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium
This commit is contained in:
Trevor BrownandClaude Sonnet 5.5 committed 2026-11-12 20:41:57 -05:00
commit 7057fbf3a5
38 files changed
+740 -60

No files matched your search

+4 -4
View File
@@ -464,7 +464,7 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe
refuse_dropping_images_from_script(input.map(|input| (input, input_images_left_out)), ds, out)?;
if ext(out) == "figcraft" {
// Through the engine, so the version history is kept.
write(out, &figcraft_engine::cmd::file::file_bytes(ds).map_err(|e| CliError::Failed(e.to_string()))?)?;
write(out, &figcraft_engine::cmd::file::file_bytes_at(ds, out).map_err(|e| CliError::Failed(e.to_string()))?)?;
} else {
write_doc(&ds.doc, ds.page, out)?;
}
@@ -670,7 +670,7 @@ mod tests {
)
.unwrap();
let out = dir.join("out.figcraft");
run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap();
assert_eq!(run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap(), ExitCode::SUCCESS);
let mut s = Session::empty();
s.execute("file.open", &json!({"path": out.to_str().unwrap()})).unwrap();
@@ -700,7 +700,7 @@ mod tests {
)
.unwrap();
let out = dir.join("out.figcraft");
run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap();
assert_eq!(run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap(), ExitCode::SUCCESS);
let mut s = Session::empty();
s.execute("file.open", &json!({"path": out.to_str().unwrap()})).unwrap();
@@ -737,7 +737,7 @@ mod tests {
.unwrap();
let out = dir.join("out.figcraft");
// The failure is the script's, not the call's: the CLI reports it and exits non-zero.
run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap();
assert_eq!(run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap(), ExitCode::from(1));
assert!(!out.exists(), "a script that failed writes nothing");
std::fs::remove_dir_all(dir).unwrap();
}
+7 -1
View File
@@ -508,7 +508,13 @@ fn measure_components(table: &mut Table, col: usize, instances: usize) -> CliRes
let lib = figcraft_engine::library::Cache::default().load(&path).map_err(failed)?;
let mut doc = Document::new("Consumer");
let m = &lib.manifest;
doc.libraries_mut().enabled.push(figcraft_doc::LibraryLink { id: m.id.clone(), name: m.name.clone(), path: path.clone(), version: m.version });
doc.libraries_mut().enabled.push(figcraft_doc::LibraryLink {
id: m.id.clone(),
name: m.name.clone(),
path: path.clone(),
version: m.version,
..Default::default()
});
let map = figcraft_engine::library::import(&mut doc, &lib, &lib_ds.assets(), false).map_err(failed)?;
let mut local = lib_ds.remap(&map);
local.theme = doc.libraries.collections.iter().find(|r| r.key == lib_ds.theme).map(|r| r.local.clone()).unwrap_or_default();
+5
View File
@@ -152,6 +152,11 @@ pub struct LibraryLink {
pub name: String,
/// Where the library file is.
pub path: String,
/// Where the library file is from the folder of the document that links it ('/'-separated;
/// written on save): followed first when the document is opened, so the link survives moving
/// or cloning the folder that holds both. Empty for server links and older files.
#[serde(skip_serializing_if = "String::is_empty")]
pub relative: String,
/// The library version this document last saw (when it was added or its updates accepted).
pub version: u64,
}
+91 -1
View File
@@ -628,6 +628,90 @@ pub fn absolute_path(path: &str) -> String {
path.to_string()
}
/// `path` with `.` and `..` taken out without asking the file system (`..` at the root stays there).
fn lexical(path: &std::path::Path) -> std::path::PathBuf {
use std::path::Component;
let mut out = std::path::PathBuf::new();
for c in path.components() {
match c {
Component::CurDir => {}
Component::ParentDir if matches!(out.components().next_back(), Some(Component::Normal(_))) => {
out.pop();
}
Component::ParentDir if out.has_root() => {}
c => out.push(c),
}
}
out
}
/// The folder of the document saved at `doc_path`, absolute.
fn doc_folder(doc_path: &str) -> Option<std::path::PathBuf> {
lexical(std::path::Path::new(&absolute_path(doc_path))).parent().map(std::path::Path::to_path_buf)
}
/// Library file `lib` from the folder of the document at `doc_path`, '/'-separated; `None` when
/// there is no such path (another drive, say).
fn relative_to(doc_path: &str, lib: &str) -> Option<String> {
use std::path::Component;
let folder = doc_folder(doc_path)?;
let lib = lexical(std::path::Path::new(&absolute_path(lib)));
let (from, to): (Vec<_>, Vec<_>) = (folder.components().collect(), lib.components().collect());
let same = from.iter().zip(&to).take_while(|(a, b)| a == b).count();
// Not even the root in common: no relative path leads there.
if same == 0 || !matches!(to.get(same..), Some([_, ..])) {
return None;
}
let up = from.get(same..).unwrap_or_default().iter().map(|_| Some(".."));
let down = to.get(same..).unwrap_or_default().iter().map(|c| match c {
Component::Normal(s) => s.to_str(),
_ => None,
});
up.chain(down).collect::<Option<Vec<_>>>().map(|parts| parts.join("/"))
}
/// Before saving the document to `doc_path`: each library file link also says where the file is
/// from the document's folder ([`LibraryLink::relative`]).
pub fn relate_links(doc: &mut Document, doc_path: &str) {
if doc.libraries.enabled.is_empty() {
return;
}
for l in &mut doc.libraries_mut().enabled {
l.relative = if crate::library_server::is_server_link(&l.path) { None } else { relative_to(doc_path, &l.path) }.unwrap_or_default();
}
}
/// After opening the document at `doc_path`: library file links go by its folder. A library
/// found at its [relative](LibraryLink::relative) place is followed (the folder was moved or
/// cloned), and a relative `path` is taken from the document's folder, not the working directory.
/// The open document keeps only the absolute `path`.
pub fn resolve_links(doc: &mut Document, doc_path: &str) {
if doc.libraries.enabled.iter().all(|l| crate::library_server::is_server_link(&l.path) && l.relative.is_empty()) {
return;
}
let Some(folder) = doc_folder(doc_path) else { return };
for l in &mut doc.libraries_mut().enabled {
if crate::library_server::is_server_link(&l.path) {
l.relative.clear();
continue;
}
let moved = Some(l.relative.as_str()).filter(|r| !r.is_empty()).map(|r| lexical(&folder.join(r)));
// Only a place on this computer is looked at (see `Session::fetch_library`).
if let Some(p) =
moved.filter(|p| p.to_str().is_some_and(craft_docpath::is_local) && crate::file_access::metadata(p).is_ok_and(|m| m.is_file()))
{
l.path = p.to_string_lossy().into_owned();
} else if !l.path.is_empty()
&& std::path::Path::new(&l.path).is_relative()
&& let Some(p) = lexical(&folder.join(&l.path)).to_str().filter(|p| craft_docpath::is_local(p))
{
l.path = p.to_string();
}
// Written again on each save, from `path`.
l.relative.clear();
}
}
/// The enabled library `key` (its id, name or path).
pub fn find_link<'a>(doc: &'a Document, key: &str) -> Option<&'a LibraryLink> {
let l = &doc.libraries.enabled;
@@ -1257,7 +1341,13 @@ pub fn adopt_pasted(doc: &mut Document, src: &Document, src_path: Option<&str>,
if doc.libraries.link(&m.id).is_none()
&& let Some(path) = src_path
{
doc.libraries_mut().enabled.push(LibraryLink { id: m.id.clone(), name: m.name.clone(), path: path.to_string(), version: m.version });
doc.libraries_mut().enabled.push(LibraryLink {
id: m.id.clone(),
name: m.name.clone(),
path: path.to_string(),
version: m.version,
relative: String::new(),
});
}
}
}
+54
View File
@@ -641,3 +641,57 @@ fn a_file_stamp_from_the_moment_it_was_read_does_not_prove_it_unchanged() {
assert!(entry(old).still_valid(&Stamp { len: 10, modified: Some(old) }));
assert!(!entry(old).still_valid(&Stamp { len: 11, modified: Some(old) }));
}
/// Library `lid` is listed without an error, and `library.updates` reports no errors.
fn link_found(s: &mut Session, lid: &str) {
let list = ex(s, "library.list", json!({}));
let l = list["libraries"].as_array().unwrap().iter().find(|l| l["id"] == lid).unwrap_or_else(|| panic!("{list}"));
assert!(l.get("error").is_none_or(Value::is_null), "{l}");
let u = ex(s, "library.updates", json!({}));
assert_eq!(u["errors"], json!([]), "{u}");
}
/// A library next to the documents that use it stays linked when their folder is moved or cloned;
/// files from before relative links, and relative paths written by hand, keep working too.
#[test]
fn library_links_follow_a_moved_folder() {
let d = dir("moved-folder");
let (a, b) = (d.join("a"), d.join("b"));
std::fs::create_dir_all(a.join("lib")).unwrap();
std::fs::create_dir_all(a.join("pages")).unwrap();
let mut lib = Session::new();
kit(&mut lib);
let lid = publish_to(&mut lib, &path_str(&a.join("lib").join("kit.figcraft")));
let mut s = Session::new();
ex(&mut s, "file.saveAs", json!({"path": path_str(&a.join("pages").join("Page.figcraft"))}));
ex(&mut s, "library.add", json!({"path": path_str(&a.join("lib").join("kit.figcraft"))}));
ex(&mut s, "library.place", json!({"library": lid, "component": "Card", "x": 0, "y": 0}));
ex(&mut s, "file.save", json!({}));
// The folder moves (or is cloned elsewhere): the library keeps its place next to the page.
std::fs::rename(&a, &b).unwrap();
let mut moved = Session::empty();
ex(&mut moved, "file.open", json!({"path": path_str(&b.join("pages").join("Page.figcraft"))}));
link_found(&mut moved, &lid);
// A file from before relative links: the absolute path is followed.
let old = d.join("old");
std::fs::create_dir_all(&old).unwrap();
let mut doc = Document::new("Old");
let abs = path_str(&b.join("lib").join("kit.figcraft"));
doc.libraries_mut().enabled.push(LibraryLink { id: lid.clone(), name: "Kit".into(), path: abs, version: 1, ..Default::default() });
std::fs::write(old.join("Old.figcraft"), figcraft_format::save_bytes(&doc, None).unwrap()).unwrap();
let mut s = Session::empty();
ex(&mut s, "file.open", json!({"path": path_str(&old.join("Old.figcraft"))}));
link_found(&mut s, &lid);
// A relative path written by hand goes by the document's folder, not the working directory.
let mut doc = Document::new("Hand");
let rel = "../lib/kit.figcraft".to_string();
doc.libraries_mut().enabled.push(LibraryLink { id: lid.clone(), name: "Kit".into(), path: rel, version: 1, ..Default::default() });
std::fs::write(b.join("pages").join("Hand.figcraft"), figcraft_format::save_bytes(&doc, None).unwrap()).unwrap();
let mut s = Session::empty();
ex(&mut s, "file.open", json!({"path": path_str(&b.join("pages").join("Hand.figcraft"))}));
link_found(&mut s, &lid);
let _ = std::fs::remove_dir_all(&d);
}
+1 -1
View File
@@ -41,7 +41,7 @@ use open::append_pages;
pub(crate) use open::open;
use open::{import, open_recent, revert};
pub(crate) use save::save_ends_preview;
pub use save::{FileSnapshot, file_bytes, thumbnail};
pub use save::{FileSnapshot, file_bytes, file_bytes_at, thumbnail};
use save::{color_profile, mark_saved, save, save_as};
pub use version::MAX_AUTOSAVES;
pub(crate) use version::{add_version, keep_in_history, read_version, unique_id};
+3 -1
View File
@@ -75,7 +75,9 @@ pub fn load_bytes(bytes: &[u8], name_hint: &str) -> Result<(Document, Vec<String
pub fn load_path(path: &str, variables: Option<&Value>) -> Result<(figcraft_format::Loaded, bool)> {
let bytes = read(path)?;
let sibling = if variables.is_none() { sibling_variables(path) } else { None };
load_with(&bytes, path, variables.or(sibling.as_ref()), &rest_images(path))
let (mut loaded, rest) = load_with(&bytes, path, variables.or(sibling.as_ref()), &rest_images(path))?;
crate::library::resolve_links(&mut loaded.doc, path);
Ok((loaded, rest))
}
/// Load from bytes. Returns the document with its notes (repairs of a native file, or import
+15 -1
View File
@@ -42,6 +42,11 @@ pub fn file_bytes(ds: &DocState) -> Result<Vec<u8>> {
FileSnapshot::of(ds).encode()
}
/// [`file_bytes`] for the file at `path` (library links are stored relative to its folder too).
pub fn file_bytes_at(ds: &DocState, path: &str) -> Result<Vec<u8>> {
FileSnapshot::of(ds).encode_at(Some(path))
}
/// A document's file as it is at one moment, so the slow part of saving it (the thumbnail and the
/// encoding) can run on another thread while editing goes on: [`encode`](Self::encode) there, then
/// [`save`](Self::save) the bytes back on the session's thread.
@@ -73,8 +78,17 @@ impl FileSnapshot {
/// The file's bytes: content, thumbnail and version history.
pub fn encode(&self) -> Result<Vec<u8>> {
self.encode_at(self.path.as_deref())
}
/// [`encode`](Self::encode) for the file at `path`: library links also say where their
/// files are from its folder.
fn encode_at(&self, path: Option<&str>) -> Result<Vec<u8>> {
let mut doc = (*self.doc).clone();
doc.current_page = Some(self.page);
if let Some(path) = path {
crate::library::relate_links(&mut doc, path);
}
let thumb = thumbnail(&doc);
figcraft_format::save_bytes_with_history(&doc, thumb.as_deref(), &self.versions, &self.branches)
.map_err(|e| EngineError::Other(e.to_string()))
@@ -177,7 +191,7 @@ fn save_to(s: &mut Session, path: Option<String>) -> Result<Value> {
{
Arc::make_mut(&mut snapshot.doc).name = file_stem(path);
}
let bytes = snapshot.encode()?;
let bytes = snapshot.encode_at(path.as_deref().or(snapshot.path.as_deref()))?;
let Some(path) = path else {
// The download can still be refused: the caller marks the file saved once it was delivered.
let name = format!("{}.figcraft", ds.doc.name);
+7 -1
View File
@@ -646,7 +646,13 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
l.path = path.clone();
l.name = m.name.clone();
}
None => libs.enabled.push(LibraryLink { id: m.id.clone(), name: m.name.clone(), path: path.clone(), version: m.version }),
None => libs.enabled.push(LibraryLink {
id: m.id.clone(),
name: m.name.clone(),
path: path.clone(),
version: m.version,
relative: String::new(),
}),
}
}
let mut out = assets_json(&lib);
+10 -4
View File
@@ -678,7 +678,7 @@ fn apply_look(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) ->
"startCap" => doc.get_mut(id)?.stroke.cap_start = enum_of(v).ok_or_else(|| bad(cmd, format!("startCap: {STROKE_CAP_HELP}")))?,
"endCap" => doc.get_mut(id)?.stroke.cap_end = enum_of(v).ok_or_else(|| bad(cmd, format!("endCap: {STROKE_CAP_HELP}")))?,
"strokeJoin" => doc.get_mut(id)?.stroke.join = enum_of(v).ok_or_else(|| bad(cmd, "strokeJoin: miter|bevel|round"))?,
"miterLimit" => doc.get_mut(id)?.stroke.miter_limit = f64_of(v).unwrap_or(4.0).clamp(1.0, 100.0),
"miterLimit" => doc.get_mut(id)?.stroke.miter_limit = f64_of(v).ok_or_else(|| bad(cmd, "`miterLimit` must be a number"))?.clamp(1.0, 100.0),
"miterAngle" => {
let deg = f64_of(v).ok_or_else(|| bad(cmd, "`miterAngle` must be degrees"))?;
doc.get_mut(id)?.stroke.miter_limit = StrokeProps::miter_limit_for(deg);
@@ -728,7 +728,9 @@ fn apply_corners(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value)
*slot = r;
}
}
"cornerSmoothing" => doc.get_mut(id)?.corners.smoothing = f64_of(v).unwrap_or(0.0).clamp(0.0, 1.0),
"cornerSmoothing" => {
doc.get_mut(id)?.corners.smoothing = f64_of(v).ok_or_else(|| bad(cmd, "`cornerSmoothing` must be a number"))?.clamp(0.0, 1.0)
}
"effects" => doc.get_mut(id)?.effects = effects_from(cmd, v)?,
"clip" | "clipsContent" => doc.get_mut(id)?.clip = v.as_bool().ok_or_else(|| bad(cmd, "`clip` must be a boolean"))?,
"isMask" => doc.get_mut(id)?.is_mask = v.as_bool().ok_or_else(|| bad(cmd, format!("`{k}` must be a boolean")))?,
@@ -1002,8 +1004,12 @@ fn apply_text(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Val
text_mut(cmd, doc.get_mut(id)?)?.auto_resize =
enum_of(v).ok_or_else(|| bad(cmd, "textAutoResize: widthAndHeight|height|none|truncate"))?
}
"paragraphSpacing" => text_mut(cmd, doc.get_mut(id)?)?.paragraph_spacing = f64_of(v).unwrap_or(0.0).max(0.0),
"paragraphIndent" => text_mut(cmd, doc.get_mut(id)?)?.paragraph_indent = f64_of(v).unwrap_or(0.0).max(0.0),
"paragraphSpacing" => {
text_mut(cmd, doc.get_mut(id)?)?.paragraph_spacing = f64_of(v).ok_or_else(|| bad(cmd, "`paragraphSpacing` must be a number"))?.max(0.0)
}
"paragraphIndent" => {
text_mut(cmd, doc.get_mut(id)?)?.paragraph_indent = f64_of(v).ok_or_else(|| bad(cmd, "`paragraphIndent` must be a number"))?.max(0.0)
}
"listStyle" | "indentation" => {
let t = text_mut(cmd, doc.get_mut(id)?)?;
let (start, end) = range_of(props, t.char_count());
+7 -1
View File
@@ -453,7 +453,13 @@ fn library_setup(path: &str) -> (Session, DesignSystem, Session, DesignSystem, V
let lib = crate::library::Cache::default().load(path).unwrap();
let mut doc = Document::new("Consumer");
let m = &lib.manifest;
doc.libraries_mut().enabled.push(LibraryLink { id: m.id.clone(), name: m.name.clone(), path: path.to_string(), version: m.version });
doc.libraries_mut().enabled.push(LibraryLink {
id: m.id.clone(),
name: m.name.clone(),
path: path.to_string(),
version: m.version,
..Default::default()
});
let map = crate::library::import(&mut doc, &lib, &ds.assets(), false).unwrap();
let mut local = ds.remap(&map);
local.theme = doc.libraries.collections.iter().find(|r| r.key == ds.theme).map(|r| r.local.clone()).unwrap();
+2 -1
View File
@@ -413,10 +413,11 @@ fn zero_padding(sides: [bool; 4]) -> Vec<&'static str> {
pub(crate) fn merge(mobile: Option<&[(String, String)]>, desktop: &[(String, String)]) -> Vec<String> {
let hidden = [("display".to_string(), "hidden".to_string())];
// A mobile frame's widths are the phone's, not caps: below the breakpoint boxes fill the page.
// Except a cap desktop has too: that is the layer's own size (a badge, an avatar), kept at every width.
let fluid: Vec<(String, String)>;
let mobile = match mobile {
Some(m) => {
fluid = m.iter().filter(|(k, _)| k != "max-width").cloned().collect();
fluid = m.iter().filter(|(k, c)| k != "max-width" || desktop.iter().any(|(_, d)| d == c)).cloned().collect();
fluid.as_slice()
}
None => &hidden,
@@ -56,6 +56,7 @@ fn a_plugin_gets_library_names_but_not_their_links() {
name: "Kit".into(),
path: LINK.into(),
version: 3,
..Default::default()
});
let tail = "(call $set_output (i32.const 40000) (call $reply_read (i32.const 40000) (i32.const 20000))) (return (i32.const 0))";
install(&mut s, manifest("org.test.libs", "read", &[]), &script(&[("library.list", json!({}))], OnRefusal::Fail, tail));
@@ -73,6 +74,7 @@ fn a_plugin_gets_a_fixed_message_for_library_update_failures() {
name: "Kit".into(),
path: LINK.into(),
version: 3,
..Default::default()
});
let tail = "(call $set_output (i32.const 40000) (call $reply_read (i32.const 40000) (i32.const 20000))) (return (i32.const 0))";
install(&mut s, manifest("org.test.upd", "read", &[]), &script(&[("library.updates", json!({}))], OnRefusal::Fail, tail));
+1 -1
View File
@@ -61,7 +61,7 @@ impl LiveLink for Link {
/// A session with `prefs` that opened a file (from disk) whose libraries include [`LINK`].
fn opened(prefs: Prefs, tag: &str) -> (Session, Arc<Mutex<Wire>>, std::path::PathBuf) {
let mut d = Document::new("Someone else's file");
d.libraries_mut().enabled.push(LibraryLink { id: "lib".into(), name: "Kit".into(), path: LINK.into(), version: 3 });
d.libraries_mut().enabled.push(LibraryLink { id: "lib".into(), name: "Kit".into(), path: LINK.into(), version: 3, ..Default::default() });
let path = std::env::temp_dir().join(format!("figcraft-beacon-{}-{tag}.figcraft", std::process::id()));
std::fs::write(&path, figcraft_format::save_bytes(&d, None).unwrap()).unwrap();
let mut s = Session::new();
@@ -570,3 +570,18 @@ fn outline_stroke_outlines_every_selected_layer() {
assert_eq!(s.selection(), ids, "the results are selected");
}
}
#[test]
fn node_set_non_numeric_miter_smoothing_paragraph_is_an_error() {
let mut s = Session::new();
let r = create(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}));
let t = create(&mut s, "text.create", json!({"text": "Hi", "x": 0, "y": 100}));
ex(&mut s, "node.set", json!({"ids": [r], "props": {"miterLimit": 8, "cornerSmoothing": 0.5}}));
ex(&mut s, "node.set", json!({"ids": [t], "props": {"paragraphSpacing": 6, "paragraphIndent": 4}}));
for (id, key) in [(r, "miterLimit"), (r, "cornerSmoothing"), (t, "paragraphSpacing"), (t, "paragraphIndent")] {
let err = s.execute("node.set", &json!({"ids": [id], "props": {key: "abc"}})).expect_err(key);
assert!(err.to_string().contains("must be a number"), "{key}: {err}");
}
assert_eq!(node(&s, r).stroke.miter_limit, 8.0);
assert_eq!(node(&s, r).corners.smoothing, 0.5);
}
+48 -2
View File
@@ -176,7 +176,11 @@ fn slashed_layer_names_and_phone_widths_merge_right() {
for (name, width, dir, sizing) in [("Landing", 1440.0, "horizontal", "hug"), ("Landing mobile", 390.0, "vertical", "fill")] {
let p = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": width, "height": 600, "name": name})));
ex(&mut s, "layout.add", json!({"ids": [p], "direction": "vertical"}));
let ctas = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 342, "height": 120, "parent": p, "name": "CTAs"})));
let ctas = idv(&ex(
&mut s,
"frame.create",
json!({"x": 0, "y": 0, "width": if width > 1000.0 { 600.0 } else { 342.0 }, "height": 120, "parent": p, "name": "CTAs"}),
));
ex(&mut s, "layout.add", json!({"ids": [ctas], "direction": dir}));
let b = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 48, "parent": ctas, "name": "Button/Primary"})));
ex(&mut s, "layout.add", json!({"ids": [b], "direction": "horizontal"}));
@@ -190,7 +194,7 @@ fn slashed_layer_names_and_phone_widths_merge_right() {
let button =
out.lines().find(|l| l.contains("lg:") && l.contains("rounded") || l.contains("self-stretch") && l.contains("flex-row")).unwrap_or_default();
assert!(button.contains("self-stretch") && button.contains("lg:self-auto"), "the slashed name keeps its mobile classes: {out}");
assert!(!out.contains("max-w-[342px] ") || out.contains("lg:max-w-[342px]"), "no phone-width cap below lg: {out}");
assert!(!out.contains("max-w-[342px]") && out.contains("lg:max-w-[600px]"), "no phone-width cap below lg: {out}");
assert!(out.contains("Only in the mobile frame") && out.contains("Tag"), "{out}");
let divider = out.lines().filter(|l| l.contains("<div") && (l.contains("h-[1px]") || l.contains("h-px"))).count();
assert!(out.contains("w-full") && divider <= 2, "{out}");
@@ -353,3 +357,45 @@ fn font_family_with_quote_and_bracket_stays_a_valid_class_token() {
assert!(after.starts_with('>') && !attr.contains('\\'), "{out}");
assert!(!inner.is_empty() && !inner.contains(['\\', '"', ']']), "{out}");
}
/// Issue #2272: a desktop `page` 1440 wide and a mobile one 390 wide, each holding a fixed 48 x 48
/// `badge` and a `banner` that fills the page.
fn badge_frames(s: &mut Session) -> (NodeId, NodeId) {
let mut frames = Vec::new();
for (width, padding, x) in [(1440.0, 80, 0), (390.0, 24, 1600)] {
let p = idv(&ex(
s,
"frame.create",
json!({"name": "page", "x": x, "y": 0, "width": width, "height": 400, "props": {"layout": "vertical", "padding": padding, "gap": 16}}),
));
let badge_props = json!({"layout": "horizontal", "sizingH": "fixed", "sizingV": "fixed", "fill": "#E8EEF7"});
ex(s, "frame.create", json!({"name": "badge", "parent": p, "width": 48, "height": 48, "props": badge_props}));
let inner = width - 2.0 * f64::from(padding);
ex(
s,
"frame.create",
json!({"name": "banner", "parent": p, "width": inner, "height": 40, "props": {"layout": "horizontal", "fill": "#112233"}}),
);
frames.push(p);
}
(frames[0], frames[1])
}
#[test]
fn a_max_width_both_frames_share_stays_unprefixed() {
let mut s = Session::new();
let (desk, mob) = badge_frames(&mut s);
let out = code(&mut s, desk, json!({"mobile": mob}));
let badge = out.lines().find(|l| l.contains("bg-[#E8EEF7]")).unwrap_or_default();
assert!(badge.contains("max-w-[48px]") && !badge.contains("lg:max-w-[48px]"), "the badge keeps its cap at every width: {out}");
}
#[test]
fn a_max_width_that_differs_still_goes_fluid_on_mobile() {
let mut s = Session::new();
let (desk, mob) = badge_frames(&mut s);
let out = code(&mut s, desk, json!({"mobile": mob}));
let banner = out.lines().find(|l| l.contains("bg-[#112233]")).unwrap_or_default();
assert!(banner.contains("lg:max-w-[1280px]"), "the page-wide box is capped from lg: {banner}");
assert!(!banner.split_whitespace().any(|c| c == "max-w-[1280px]" || c == "max-w-[342px]"), "no phone-width cap below lg: {banner}");
}
+2 -1
View File
@@ -100,6 +100,7 @@ enum TextCase ORIGINAL=0 UPPER=1 LOWER=2 TITLE=3 SMALL_CAPS=4
enum TextDecoration NONE=0 UNDERLINE=1 STRIKETHROUGH=2
enum NumberUnits RAW=0 PIXELS=1 PERCENT=2
enum LineType PLAIN=0 ORDERED_LIST=1 UNORDERED_LIST=2
enum LeadingTrim NONE=0 CAP_HEIGHT=1
enum SourceDirectionality AUTO=0 LTR=1 RTL=2
enum DocumentColorProfile LEGACY=0 SRGB=1 DISPLAY_P3=2
enum EmojiImageSet APPLE=0 NOTO=1
@@ -189,7 +190,7 @@ message GridTrackSizingFunction GridTrackSizingType type=1, float value=2
message GridTrackSize GridTrackSizingFunction minSizing=1, GridTrackSizingFunction maxSizing=2
message GUIDGridTrackSizeMapEntry GUID id=1, GridTrackSize trackSize=2
message GUIDGridTrackSizeMap GUIDGridTrackSizeMapEntry[] entries=1
message NodeChange GUID guid=1, NodePhase phase=2, ParentIndex parentIndex=3, NodeType type=4, string name=5, bool visible=6, bool locked=7, float opacity=8, BlendMode blendMode=9, Vector size=11, Matrix transform=12, bool mask=16, MaskType maskType=317, float backgroundOpacity=19, bool backgroundEnabled=15, bool internalOnly=142, string description=318, StyleType styleType=163, string sortPosition=320, StyleId styleIdForFill=332, StyleId styleIdForStrokeFill=333, StyleId styleIdForText=334, StyleId styleIdForEffect=335, StyleId styleIdForGrid=336, VariableDataMap variableConsumptionMap=307, VariableModeBySetMap variableModeBySetMap=316, VariableSetMode[] variableSetModes=312, VariableSetID variableSetID=313, VariableResolvedDataType variableResolvedType=314, VariableDataValues variableDataValues=315, VariableScope[] variableScopes=353, DocumentColorProfile documentColorProfile=349, Paint[] fillPaints=38, Paint[] strokePaints=39, float strokeWeight=26, StrokeAlign strokeAlign=29, StrokeJoin strokeJoin=31, StrokeCap strokeCap=30, float[] dashPattern=13, float miterLimit=25, bool borderStrokeWeightsIndependent=299, float borderTopWeight=295, float borderRightWeight=298, float borderBottomWeight=296, float borderLeftWeight=297, Effect[] effects=43, float cornerRadius=20, float cornerSmoothing=160, bool rectangleCornerRadiiIndependent=149, float rectangleTopLeftCornerRadius=145, float rectangleTopRightCornerRadius=146, float rectangleBottomRightCornerRadius=148, float rectangleBottomLeftCornerRadius=147, ConstraintType horizontalConstraint=28, ConstraintType verticalConstraint=37, bool frameMaskDisabled=115, bool resizeToFit=117, bool isStateGroup=225, StateGroupPropertyValueOrder[] stateGroupPropertyValueOrders=238, bool sectionContentsHidden=283, Color backgroundColor=50, StackMode stackMode=105, float stackSpacing=107, float stackCounterSpacing=324, StackWrap stackWrap=323, StackCounterAlignContent stackCounterAlignContent=343, float stackHorizontalPadding=209, float stackVerticalPadding=210, float stackPaddingRight=233, float stackPaddingBottom=234, StackJustify stackPrimaryAlignItems=230, StackAlign stackCounterAlignItems=231, StackSize stackPrimarySizing=229, StackSize stackCounterSizing=221, bool bordersTakeSpace=294, bool stackReverseZIndex=271, float gridColumnGap=438, float gridRowGap=437, GUIDPositionMap gridColumns=436, GUIDPositionMap gridRows=435, GUIDGridTrackSizeMap gridColumnsSizing=474, GUIDGridTrackSizeMap gridRowsSizing=475, float stackChildPrimaryGrow=232, StackCounterAlign stackChildAlignSelf=236, StackPositioning stackPositioning=269, OptionalVector minSize=325, OptionalVector maxSize=326, GUID gridColumnAnchor=440, GUID gridRowAnchor=439, uint gridColumnSpan=442, uint gridRowSpan=441, LayoutGrid[] layoutGrids=47, ExportSettings[] exportSettings=45, TextData textData=42, FontName fontName=41, float fontSize=21, Number lineHeight=40, Number letterSpacing=165, TextCase textCase=34, TextDecoration textDecoration=35, TextAlignHorizontal textAlignHorizontal=32, TextAlignVertical textAlignVertical=33, TextAutoResize textAutoResize=46, TextTruncation textTruncation=280, int maxLines=351, float paragraphSpacing=23, float paragraphIndent=22, Hyperlink hyperlink=223, bool autoRename=14, bool fontVariantCommonLigatures=127, bool fontVariantContextualLigatures=128, string fontVersion=202, uint textUserLayoutVersion=203, uint textExplicitLayoutVersion=396, uint textBidiVersion=279, float textTracking=27, bool textDecorationSkipInk=412, EmojiImageSet emojiImageSet=391, uint styleID=49, Path[] fillGeometry=51, Path[] strokeGeometry=52, VectorData vectorData=48, VectorMirror handleMirroring=44, uint count=10, float starInnerScale=24, ArcData arcData=195, BooleanOperation booleanOperation=36, string symbolDescription=144, VariableWidthPoint[] variableWidthPoints=447, PluginData[] pluginData=204, SymbolData symbolData=113, NodeChange[] derivedSymbolData=125, GUID overriddenSymbolID=143, ComponentPropDef[] componentPropDefs=266, ComponentPropRef[] componentPropRefs=267, ComponentPropAssignment[] componentPropAssignments=268, VariantPropSpec[] variantPropSpecs=483, VariableDataMap parameterConsumptionMap=445, GUIDPath guidPath=111, PrototypeInteraction[] prototypeInteractions=226, PrototypeStartingPoint prototypeStartingPoint=249, ScrollDirection scrollDirection=159, ScrollBehavior scrollBehavior=186, OverlayPositionType overlayPositionType=198, OverlayBackgroundInteraction overlayBackgroundInteraction=200, OverlayBackgroundAppearance overlayBackgroundAppearance=201
message NodeChange GUID guid=1, NodePhase phase=2, ParentIndex parentIndex=3, NodeType type=4, string name=5, bool visible=6, bool locked=7, float opacity=8, BlendMode blendMode=9, Vector size=11, Matrix transform=12, bool mask=16, MaskType maskType=317, float backgroundOpacity=19, bool backgroundEnabled=15, bool internalOnly=142, string description=318, StyleType styleType=163, string sortPosition=320, StyleId styleIdForFill=332, StyleId styleIdForStrokeFill=333, StyleId styleIdForText=334, StyleId styleIdForEffect=335, StyleId styleIdForGrid=336, VariableDataMap variableConsumptionMap=307, VariableModeBySetMap variableModeBySetMap=316, VariableSetMode[] variableSetModes=312, VariableSetID variableSetID=313, VariableResolvedDataType variableResolvedType=314, VariableDataValues variableDataValues=315, VariableScope[] variableScopes=353, DocumentColorProfile documentColorProfile=349, Paint[] fillPaints=38, Paint[] strokePaints=39, float strokeWeight=26, StrokeAlign strokeAlign=29, StrokeJoin strokeJoin=31, StrokeCap strokeCap=30, float[] dashPattern=13, float miterLimit=25, bool borderStrokeWeightsIndependent=299, float borderTopWeight=295, float borderRightWeight=298, float borderBottomWeight=296, float borderLeftWeight=297, Effect[] effects=43, float cornerRadius=20, float cornerSmoothing=160, bool rectangleCornerRadiiIndependent=149, float rectangleTopLeftCornerRadius=145, float rectangleTopRightCornerRadius=146, float rectangleBottomRightCornerRadius=148, float rectangleBottomLeftCornerRadius=147, ConstraintType horizontalConstraint=28, ConstraintType verticalConstraint=37, bool frameMaskDisabled=115, bool resizeToFit=117, bool isStateGroup=225, StateGroupPropertyValueOrder[] stateGroupPropertyValueOrders=238, bool sectionContentsHidden=283, Color backgroundColor=50, StackMode stackMode=105, float stackSpacing=107, float stackCounterSpacing=324, StackWrap stackWrap=323, StackCounterAlignContent stackCounterAlignContent=343, float stackHorizontalPadding=209, float stackVerticalPadding=210, float stackPaddingRight=233, float stackPaddingBottom=234, StackJustify stackPrimaryAlignItems=230, StackAlign stackCounterAlignItems=231, StackSize stackPrimarySizing=229, StackSize stackCounterSizing=221, bool bordersTakeSpace=294, bool stackReverseZIndex=271, float gridColumnGap=438, float gridRowGap=437, GUIDPositionMap gridColumns=436, GUIDPositionMap gridRows=435, GUIDGridTrackSizeMap gridColumnsSizing=474, GUIDGridTrackSizeMap gridRowsSizing=475, float stackChildPrimaryGrow=232, StackCounterAlign stackChildAlignSelf=236, StackPositioning stackPositioning=269, OptionalVector minSize=325, OptionalVector maxSize=326, GUID gridColumnAnchor=440, GUID gridRowAnchor=439, uint gridColumnSpan=442, uint gridRowSpan=441, LayoutGrid[] layoutGrids=47, ExportSettings[] exportSettings=45, TextData textData=42, FontName fontName=41, float fontSize=21, Number lineHeight=40, Number letterSpacing=165, TextCase textCase=34, TextDecoration textDecoration=35, TextAlignHorizontal textAlignHorizontal=32, TextAlignVertical textAlignVertical=33, TextAutoResize textAutoResize=46, TextTruncation textTruncation=280, int maxLines=351, float paragraphSpacing=23, float paragraphIndent=22, float listSpacing=264, LeadingTrim leadingTrim=322, bool hangingPunctuation=337, bool hangingList=339, Hyperlink hyperlink=223, bool autoRename=14, bool fontVariantCommonLigatures=127, bool fontVariantContextualLigatures=128, string fontVersion=202, uint textUserLayoutVersion=203, uint textExplicitLayoutVersion=396, uint textBidiVersion=279, float textTracking=27, bool textDecorationSkipInk=412, EmojiImageSet emojiImageSet=391, uint styleID=49, Path[] fillGeometry=51, Path[] strokeGeometry=52, VectorData vectorData=48, VectorMirror handleMirroring=44, uint count=10, float starInnerScale=24, ArcData arcData=195, BooleanOperation booleanOperation=36, string symbolDescription=144, VariableWidthPoint[] variableWidthPoints=447, PluginData[] pluginData=204, SymbolData symbolData=113, NodeChange[] derivedSymbolData=125, GUID overriddenSymbolID=143, ComponentPropDef[] componentPropDefs=266, ComponentPropRef[] componentPropRefs=267, ComponentPropAssignment[] componentPropAssignments=268, VariantPropSpec[] variantPropSpecs=483, VariableDataMap parameterConsumptionMap=445, GUIDPath guidPath=111, PrototypeInteraction[] prototypeInteractions=226, PrototypeStartingPoint prototypeStartingPoint=249, ScrollDirection scrollDirection=159, ScrollBehavior scrollBehavior=186, OverlayPositionType overlayPositionType=198, OverlayBackgroundInteraction overlayBackgroundInteraction=200, OverlayBackgroundAppearance overlayBackgroundAppearance=201
struct Blob byte[] bytes
message Message MessageType type=1, uint sessionID=2, NodeChange[] nodeChanges=4, Blob[] blobs=6
";
+3
View File
@@ -195,6 +195,9 @@ fn unset(key: &str) -> Option<Kv<'static>> {
"scrollBehavior" => e("SCROLLS"),
"parameterConsumptionMap" => o(vec![("entries", arr(Vec::new()))]),
"textTruncation" => e("DISABLED"),
"listSpacing" => fl(0.0),
"leadingTrim" => e("NONE"),
"hangingPunctuation" | "hangingList" => b(false),
"hyperlink" => o(vec![("url", s(""))]),
_ => return None,
})
+16 -2
View File
@@ -44,6 +44,20 @@ impl<'d> Exporter<'d> {
("textTracking", fl(0.0)),
("emojiImageSet", e("APPLE")),
]);
// Written only off their defaults: a text at the defaults carries none of these four in a design file saved by
// Figma. A text style holds none of them, so a layer at the defaults has no style value to take on import.
if td.list_spacing != 0.0 {
f.push(("listSpacing", fl(td.list_spacing)));
}
if td.hanging_punctuation {
f.push(("hangingPunctuation", b(true)));
}
if td.hanging_list {
f.push(("hangingList", b(true)));
}
if td.leading_trim == figcraft_doc::LeadingTrim::CapHeight {
f.push(("leadingTrim", e("CAP_HEIGHT")));
}
if td.truncates() {
f.push(("textTruncation", e("ENDING")));
}
@@ -162,8 +176,8 @@ impl<'d> Exporter<'d> {
if td.path.is_some() {
self.warn("text on a path was written as plain text");
}
if td.list_spacing != 0.0 || td.hanging_punctuation || td.hanging_list || td.leading_trim != Default::default() || !td.features.is_empty() {
self.warn("list spacing, hanging punctuation, leading trim and OpenType features were left out");
if !td.features.is_empty() {
self.warn("OpenType features were left out");
}
}
+84 -5
View File
@@ -111,12 +111,69 @@ fn encoded_fig_maps_paragraph_list_fields_through_native_round_trip() {
assert!(!text.hanging_list);
assert!(!text.hanging_punctuation);
assert_eq!(text.leading_trim, figcraft_doc::LeadingTrim::CapHeight);
}
let (_, export_warnings) = export(&inherited, None).unwrap();
assert!(
export_warnings.iter().any(|warning| warning.contains("list spacing, hanging punctuation, leading trim")),
"existing .fig export loss must remain visible: {export_warnings:?}"
);
#[test]
fn exported_paragraph_options_survive_a_fig_round_trip() {
let shared = vec![
("listSpacing", Kv::Float(18.0)),
("hangingList", Kv::Bool(false)),
("hangingPunctuation", Kv::Bool(false)),
("leadingTrim", Kv::Enum("CAP_HEIGHT")),
];
let reset = vec![
("listSpacing", Kv::Float(0.0)),
("hangingList", Kv::Bool(false)),
("hangingPunctuation", Kv::Bool(false)),
("leadingTrim", Kv::Enum("NONE")),
];
let (inherited, _) = import(&paragraph_fig(shared.clone(), vec![]), "Synthetic paragraph options").unwrap();
let (cleared, _) = import(&paragraph_fig(shared, reset), "Synthetic paragraph resets").unwrap();
// All four off their defaults: the export writes them and the import reads them back.
let mut styled = inherited.clone();
let id = styled.nodes().find(|node| node.name == "Label").unwrap().id;
if let Some(figcraft_doc::NodeKind::Text(td)) = styled.node_mut(id).map(|node| &mut node.kind) {
td.hanging_punctuation = true;
td.hanging_list = true;
}
let (bytes, export_warnings) = export(&styled, None).unwrap();
assert!(export_warnings.iter().all(|warning| !warning.contains("list spacing")), "{export_warnings:?}");
let (back, _) = import(&bytes, "Exported paragraph options").unwrap();
let text = back.nodes().find(|node| node.name == "Label").unwrap().kind.text().unwrap();
assert_eq!(text.list_spacing, 18.0);
assert!(text.hanging_list);
assert!(text.hanging_punctuation);
assert_eq!(text.leading_trim, figcraft_doc::LeadingTrim::CapHeight);
// Everything at its default: nothing is carried over.
let (bytes, export_warnings) = export(&cleared, None).unwrap();
assert!(export_warnings.iter().all(|warning| !warning.contains("list spacing")), "{export_warnings:?}");
let (back, _) = import(&bytes, "Exported paragraph defaults").unwrap();
let text = back.nodes().find(|node| node.name == "Label").unwrap().kind.text().unwrap();
assert_eq!(text.list_spacing, 0.0);
assert!(!text.hanging_list);
assert!(!text.hanging_punctuation);
assert_eq!(text.leading_trim, figcraft_doc::LeadingTrim::None);
// OpenType features are still left out, and only a text that has some warns.
let id = styled.nodes().find(|node| node.name == "Label").unwrap().id;
if let Some(figcraft_doc::NodeKind::Text(td)) = styled.node_mut(id).map(|node| &mut node.kind) {
td.features.insert("liga".into(), false);
}
let (_, with_features) = export(&styled, None).unwrap();
assert!(with_features.iter().any(|warning| warning.contains("OpenType features were left out")), "{with_features:?}");
let (_, without) = export(&cleared, None).unwrap();
assert!(without.iter().all(|warning| !warning.contains("OpenType")), "{without:?}");
}
#[test]
fn the_schema_numbers_the_paragraph_options() {
let s = super::kiwi::Schema::parse(super::export::SCHEMA).unwrap();
let def = |n: &str| s.defs.iter().find(|d| d.name == n).unwrap();
let id = |f: &str| def("NodeChange").fields.iter().find(|x| x.name == f).unwrap().value;
assert_eq!((id("listSpacing"), id("leadingTrim"), id("hangingPunctuation"), id("hangingList")), (264, 322, 337, 339));
let values: Vec<_> = def("LeadingTrim").fields.iter().map(|x| (x.name.as_str(), x.value)).collect();
assert_eq!(values, [("NONE", 0), ("CAP_HEIGHT", 1)]);
}
fn instance_paragraph_fig(overrides: Vec<(&'static str, Kv<'static>)>, nonfinite_spacing: bool) -> Vec<u8> {
@@ -286,3 +343,25 @@ fn encoded_instance_paragraph_overrides_fall_back_only_when_invalid() {
assert!(!text.hanging_punctuation);
assert_eq!(text.leading_trim, figcraft_doc::LeadingTrim::CapHeight);
}
#[test]
fn an_instance_layer_set_back_to_the_defaults_survives_a_fig_round_trip() {
// The main's text has all four off their defaults (from its text style); the instance's copy sets them back.
let reset = vec![
("listSpacing", Kv::Float(0.0)),
("hangingList", Kv::Bool(false)),
("hangingPunctuation", Kv::Bool(false)),
("leadingTrim", Kv::Enum("NONE")),
];
let (doc, _) = import(&instance_paragraph_fig(reset, false), "Synthetic instance paragraph resets").unwrap();
let (bytes, _) = export(&doc, None).unwrap();
let (back, _) = import(&bytes, "Exported instance paragraph resets").unwrap();
let options = |parent: &str| {
let parent = back.nodes().find(|node| node.name == parent).unwrap();
let text = back.node(*parent.children.first().unwrap()).unwrap().kind.text().unwrap();
(text.list_spacing, text.hanging_list, text.hanging_punctuation, text.leading_trim)
};
assert_eq!(options("Main"), (18.0, true, true, figcraft_doc::LeadingTrim::CapHeight));
// The override row clears each field; without it the instance's text takes the main's values again.
assert_eq!(options("Copy"), (0.0, false, false, figcraft_doc::LeadingTrim::None));
}
+12
View File
@@ -63,7 +63,15 @@ fn number_past(d: &str, max: f64) -> bool {
big
}
let (mut cur, mut dot, mut exp) = (String::new(), false, false);
// Whether the command is an arc, and how many numbers it has had: the 4th and 5th of each
// seven are flags, single `0`/`1` characters that may run into the next number unseparated.
let (mut arc, mut idx) = (false, 0usize);
for c in d.chars() {
if cur.is_empty() && arc && matches!(idx % 7, 3 | 4) && matches!(c, '0' | '1') {
idx += 1;
(dot, exp) = (false, false);
continue;
}
let continues = match c {
'0'..='9' => true,
'.' => !dot && !exp,
@@ -77,9 +85,13 @@ fn number_past(d: &str, max: f64) -> bool {
cur.push(c);
continue;
}
idx += cur.chars().any(|c| c.is_ascii_digit()) as usize;
if big(&mut cur, max) {
return true;
}
if c.is_ascii_alphabetic() {
(arc, idx) = (matches!(c, 'A' | 'a'), 0);
}
(dot, exp) = (c == '.', false);
if matches!(c, '+' | '-' | '.') {
cur.push(c);
+6
View File
@@ -306,6 +306,12 @@ fn round_to_handles_large_precision_without_changing_finite_values() {
assert_eq!(round_to(1.23456, 2), 1.23);
}
#[test]
fn round_to_keeps_huge_finite_values_finite() {
assert_eq!(round_to(1e307, 2), 1e307);
assert_eq!(round_to(-1e307, 2), -1e307);
}
#[test]
fn reverse_subpaths_preserves_open_and_closed() {
let mut open = BezPath::new();
+6 -1
View File
@@ -60,6 +60,11 @@ pub fn round_to(v: f64, digits: i32) -> f64 {
if v.is_finite() && !f.is_finite() {
return v;
}
let r = (v * f).round() / f;
let scaled = v * f;
if v.is_finite() && !scaled.is_finite() {
// Too large to have a fractional part at this scale; already whole.
return v;
}
let r = scaled.round() / f;
if r == 0.0 { 0.0 } else { r }
}
+14
View File
@@ -0,0 +1,14 @@
use figcraft_geom::svg_path;
#[test]
fn compact_arc_flags_are_not_part_of_the_next_number() {
let compact = svg_path("M0 0A5 5 0 11999999999 0").expect("compact flags parse");
let spaced = svg_path("M0 0A5 5 0 1 1 999999999 0").expect("spaced flags parse");
assert_eq!(compact, spaced);
}
#[test]
fn a_real_oversized_arc_coordinate_is_still_refused() {
assert!(svg_path("M0 0A5 5 0 112000000000 0").is_err());
assert!(svg_path("M0 0A5 5 0 1 1 2000000000 0").is_err());
}
+22 -5
View File
@@ -1,5 +1,5 @@
//! Renaming layers in the layers panel, driven through egui headless: a double-click or Cmd/Ctrl+R
//! starts it, Tab / Shift+Tab commit and go on to the next / previous row (the selection follows),
//! starts it, Tab / Shift+Tab commit and go on to the next / previous row (the selection stays),
//! Enter commits and Escape cancels.
use egui::{Event, Key, Modifiers};
@@ -66,7 +66,7 @@ fn tab_and_shift_tab_commit_and_rename_the_next_and_previous_rows() {
press(&mut u, Key::Tab, Modifiers::NONE);
assert_eq!(name(&u, c), "Top");
assert_eq!(renaming(&u), Some(frame), "Tab renames the row below");
assert_eq!(u.app.session.selection(), vec![frame], "the selection follows the rename");
assert_eq!(u.app.session.selection(), vec![c], "the selection stays on the first layer");
u.frame(vec![]);
// The whole name is selected: typing replaces it.
type_text(&mut u, "Box");
@@ -88,16 +88,33 @@ fn tab_and_shift_tab_commit_and_rename_the_next_and_previous_rows() {
}
#[test]
fn tab_on_the_last_row_commits_and_ends() {
let (mut u, a, _, _, _) = setup();
fn tab_on_the_last_row_commits_and_wraps_to_the_first() {
// Measured in Figma's web app, 2026-11-12: Tab on the last row commits and opens the first
// row's rename, and the selection stays where it was.
let (mut u, a, _, _, c) = setup();
double_click(&mut u, a);
type_text(&mut u, "Bottom");
press(&mut u, Key::Tab, Modifiers::NONE);
assert_eq!(name(&u, a), "Bottom");
assert_eq!(renaming(&u), None);
assert_eq!(renaming(&u), Some(c), "Tab on the last row renames the first");
assert_eq!(u.app.session.selection(), vec![a]);
}
#[test]
fn tab_while_renaming_leaves_the_selection_on_the_first_layer() {
// Measured in Figma's web app, 2026-11-12 (second run): Tab and Shift+Tab open the next and
// previous row in edit, and the selection stays on the layer first renamed.
let (mut u, _, frame, _, c) = setup();
double_click(&mut u, c);
press(&mut u, Key::Tab, Modifiers::NONE);
assert_eq!(renaming(&u), Some(frame), "Tab opens the next row's rename");
assert_eq!(u.app.session.selection(), vec![c], "the selection stays on the first layer");
u.frame(vec![]);
press(&mut u, Key::Tab, Modifiers::SHIFT);
assert_eq!(renaming(&u), Some(c), "Shift+Tab opens the previous row's rename");
assert_eq!(u.app.session.selection(), vec![c], "the selection stays on the first layer");
}
#[test]
fn cmd_r_renames_the_selection_and_opens_its_parents() {
// Cmd+R on macOS; Ctrl+R elsewhere, where egui sets both `ctrl` and `command`. On macOS Ctrl
+2
View File
@@ -198,6 +198,8 @@ mod open_issues_tests;
#[cfg(test)]
mod padding_individual_tests;
#[cfg(test)]
mod page_variable_modes_tests;
#[cfg(test)]
mod palette_tests;
#[cfg(test)]
mod panel_remove_tests;
+7 -1
View File
@@ -13,7 +13,13 @@ const LINK: &str = "ws://tracker.example:9/?file=f&token=per-file-secret";
#[test]
fn the_assets_panel_checks_an_unknown_library_server_only_when_asked() {
let mut s = Session::new();
s.doc_mut().unwrap().libraries_mut().enabled.push(LibraryLink { id: "lib".into(), name: "Kit".into(), path: LINK.into(), version: 1 });
s.doc_mut().unwrap().libraries_mut().enabled.push(LibraryLink {
id: "lib".into(),
name: "Kit".into(),
path: LINK.into(),
version: 1,
..Default::default()
});
let mut u = Ui::new(s);
u.app.ui.left_tab = crate::LeftTab::Assets;
u.app.ui.libraries = true;
@@ -0,0 +1,52 @@
//! The Design panel's Variable modes section for the page, driven headless: with nothing
//! selected a collection with several modes lists them, as Figma's Page section does.
use egui::{Event, Modifiers, PointerButton, Pos2};
use figcraft_engine::Session;
use serde_json::json;
use crate::layers_drag_tests::Ui;
use crate::live_tests::{flat, text_center};
/// A click at `p`, and the frames that follow; what the last frame drew.
fn click(u: &mut Ui, p: Pos2) -> Vec<egui::Shape> {
for _ in 0..5 {
u.frame(vec![Event::PointerMoved(p)]);
}
let mut shapes = Vec::new();
for pressed in [Some(true), Some(false)].into_iter().chain(std::iter::repeat_n(None, 20)) {
let events = pressed.map(|pressed| Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
shapes = flat(&u.frame_output(events.into_iter().collect()).shapes);
}
shapes
}
fn scene(modes: &[&str]) -> Ui {
let mut s = Session::new();
s.execute("variable.createCollection", &json!({"name": "Theme", "modes": modes})).unwrap();
s.execute("select.set", &json!({"ids": []})).unwrap();
Ui::new(s)
}
#[test]
fn with_nothing_selected_the_page_shows_its_variable_modes() {
let mut u = scene(&["Light", "Dark"]);
let shapes = flat(&u.frame_output(vec![]).shapes);
assert!(crate::live_tests::texts(&shapes).iter().any(|(t, ..)| t == crate::i18n::tr("Variable modes")), "the section shows");
let history = u.app.session.undo_depth().0;
let menu = click(&mut u, text_center(&shapes, "Auto (Light)"));
click(&mut u, text_center(&menu, " Dark"));
let page = u.app.session.page().unwrap();
let doc = u.app.session.doc().unwrap();
let coll = doc.variables.collections[0].clone();
let dark = coll.modes.iter().find(|m| m.name == "Dark").unwrap().id.clone();
assert_eq!(doc.node(page).unwrap().var_modes.get(&coll.id), Some(&dark));
assert_eq!(u.app.session.undo_depth().0, history + 1, "one undo step");
}
#[test]
fn the_page_section_has_no_variable_modes_without_a_multi_mode_collection() {
let mut u = scene(&["Only"]);
let shapes = flat(&u.frame_output(vec![]).shapes);
assert!(!crate::live_tests::texts(&shapes).iter().any(|(t, ..)| t == crate::i18n::tr("Variable modes")));
}
+7 -3
View File
@@ -719,7 +719,7 @@ pub(crate) fn collapse_all(app: &mut FigApp) {
app.ui.expanded.extend(ds.selection.iter().flat_map(|s| ds.doc.ancestors(*s)).filter(|a| *a != ds.page));
}
/// Rename `id` in its row, selected (the selection follows a rename that moves with Tab).
/// Rename `id` in its row, selected.
fn rename_layer(app: &mut FigApp, id: NodeId) {
app.ui.rename = Some(Rename { id, start: true });
if app.session.selection() != [id] {
@@ -1127,7 +1127,9 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
scroll_to = Some(i);
} else if let Some(back) = crate::widgets::take_tab(ui) {
ui.memory_mut(|m| m.surrender_focus(rename_id(rn.id).with("te")));
rename_next = if back { i.checked_sub(1) } else { Some(i + 1) }.and_then(|j| rows.get(j)).map(|r| r.id);
// Tab on the last row wraps to the first (measured in Figma's web app); Shift+Tab
// on the first row ends the rename (not measured).
rename_next = if back { i.checked_sub(1).and_then(|j| rows.get(j)) } else { rows.get(i + 1).or(rows.first()) }.map(|r| r.id);
}
rename_row = rows.get(i).map(|r| (i, r.id, r.depth));
}
@@ -1364,7 +1366,9 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
app.run("object.rename", json!({"ids": [id], "name": new}));
}
if let Some(id) = rename_next {
rename_layer(app, id);
// Only the rename moves: the selection stays on the layer first renamed (measured in
// Figma's web app, 2026-11-12).
app.ui.rename = Some(Rename { id, start: true });
}
if renaming && app.ui.rename.is_none() {
a11y::refocus_row(&mut app.ui.nav);
+4 -1
View File
@@ -37,7 +37,7 @@ pub(crate) use menu::menu_row_width;
pub(crate) use menu::preview_blend;
pub use menu::{blend_menu, context_popup, menu_check, menu_icon_item, menu_item, menu_item_beside_checks, menu_item_with, menu_popup, menu_row};
pub(crate) use menu::{block, menu_row_lit, menu_style, row, row_rect};
use paints::{appearance, paints, variable_modes};
use paints::{appearance, page_variable_modes, paints, variable_modes};
pub(crate) use stroke::{CAP_ART_W, cap_art, join_label};
/// Common value of `f` across `ids` (None when mixed or empty).
@@ -374,6 +374,9 @@ fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) {
}
}
widgets::separator(ui);
if page_variable_modes(app, ui, doc, page) {
widgets::separator(ui);
}
widgets::section_header(ui, tr("Color profile"), &[], false);
row(ui, 32.0, |ui| {
let resp = widgets::dropdown(ui, doc.color_profile.label(), 140.0, None);
+23 -4
View File
@@ -158,6 +158,17 @@ pub(super) fn variable_modes(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel:
if !crate::selection_cache::every(doc, sel, |n| n.kind.is_frame_like()) {
return false;
}
mode_section(app, ui, doc, sel, false)
}
/// The same section for the current page, with nothing selected: Auto there is the collection's
/// default mode, and a pick sets the page's own (`variable.setMode` without layers).
pub(super) fn page_variable_modes(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) -> bool {
mode_section(app, ui, doc, &[page], true)
}
/// The Variable modes section of `sel` (the page when `on_page`): one row per multi-mode collection.
fn mode_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], on_page: bool) -> bool {
let libs = crate::variable_ui::libraries(app, doc);
let rows = crate::variable_ui::mode_rows(doc, &libs);
if rows.is_empty() {
@@ -217,7 +228,7 @@ pub(super) fn variable_modes(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel:
}
}
if let Some(mode) = pick {
set_variable_mode(app, sel, r, mode);
set_variable_mode(app, if on_page { &[] } else { sel }, r, mode);
ui.close();
}
});
@@ -227,15 +238,23 @@ pub(super) fn variable_modes(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel:
true
}
/// Set `sel`'s mode of the collection of `row` (`mode` null: Auto). A library collection goes
/// Set `sel`'s mode of the collection of `row` (`mode` null: Auto; no `sel`: the page's). A library collection goes
/// through `library.setVariableMode` (copying it in); Auto on one never used here is already so.
fn set_variable_mode(app: &mut FigApp, sel: &[NodeId], row: &crate::variable_ui::ModeRow, mode: Value) {
match (row.library, row.key, row.local) {
(Some((lib, _)), Some(key), _) if !mode.is_null() => {
app.run("library.setVariableMode", json!({"library": lib, "collection": key, "ids": sel, "mode": mode}));
let mut p = json!({"library": lib, "collection": key, "mode": mode});
if !sel.is_empty() {
p["ids"] = json!(sel);
}
app.run("library.setVariableMode", p);
}
(_, _, Some(local)) => {
app.run("variable.setMode", json!({"ids": sel, "collection": local, "mode": mode}));
let mut p = json!({"collection": local, "mode": mode});
if !sel.is_empty() {
p["ids"] = json!(sel);
}
app.run("variable.setMode", p);
}
_ => {}
}
+1 -4
View File
@@ -521,10 +521,7 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option<Key>, m: Modifiers, re
}
return true;
}
if m.is_none() && !repeat && opacity_digit(app, key, now) {
return true;
}
false
m.is_none() && !repeat && opacity_digit(app, key, now)
}
/// Keys while a selection box drawn from the keyboard shows (`select.area`, Alt+Space or Ctrl+Space,
+3 -1
View File
@@ -100,7 +100,9 @@ fn a_collection_of_this_file_extends_from_its_context_menu() {
u.frame(vec![]);
}
let shapes = flat(&u.frame_output(vec![]).shapes);
click_with(&mut u, text_center(&shapes, "Theme"), PointerButton::Secondary);
// The window's row: the Design panel's Page section names the collection too (its modes), under it.
let (_, at, size) = crate::live_tests::texts(&shapes).into_iter().rfind(|(t, ..)| t == "Theme").expect("the collection's row");
click_with(&mut u, at + size / 2.0, PointerButton::Secondary);
let menu = flat(&u.frame_output(vec![]).shapes);
for item in ["Rename", "Extend collection", "Delete collection"] {
assert!(has_text(&menu, item), "{item} in the menu");
+3 -2
View File
@@ -1,6 +1,6 @@
# Components, variants, styles and variables parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (a group's context menu in the Local variables window has Duplicate group, a copy named after it plus " 2" holding a copy of every variable, in one undo step (#2084); a text style binds a number variable to its weight (#2174); an instance lists only the properties its variant's layers use and long property names are cut to one line (#2022); with several layers selected the Design panel header's component button offers Create multiple components (#2088); the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on the selected local variables in one undo step (#2002); text styles carry paragraph spacing and indent, the list style and the OpenType features, as the Supported in text styles table of Create and apply text styles says (#1829); a layer's effect binding takes only a field its effect has; Go to main component on an instance of a library component opens the library file in a tab and selects the main there; glass effects take variables; Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (a group's context menu in the Local variables window has Duplicate group, a copy named after it plus " 2" holding a copy of every variable, in one undo step (#2084); pages take a variable mode: with nothing selected the Design panel's Page section shows a Variable modes section, Auto or a mode of each collection that has more than one, as the help center says ("Apply variable mode" in the Page section of the right sidebar) (#2215); a text style binds a number variable to its weight (#2174); an instance lists only the properties its variant's layers use and long property names are cut to one line (#2022); with several layers selected the Design panel header's component button offers Create multiple components (#2088); the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on the selected local variables in one undo step (#2002); text styles carry paragraph spacing and indent, the list style and the OpenType features, as the Supported in text styles table of Create and apply text styles says (#1829); a layer's effect binding takes only a field its effect has; Go to main component on an instance of a library component opens the library file in a tab and selects the main there; glass effects take variables; Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and
variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300
@@ -71,7 +71,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 and colour calculations reading it keep the colour they show |
| Code syntax per platform (Web, iOS, Android) | done | generated CSS uses the Web name, SwiftUI the iOS one and Compose the Android one (a variable without one keeps its value there); in the variables JSON; ArkUI and Flutter have no platform of their own and write values |
| 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, an effect's only where its effect has the field (as in effect styles); glass fields too (light angle, intensity, refraction, depth, dispersion, frost and splay, percentages as the Design panel shows them, a light angle past ±180 turned round); number variables fill text content; the Design panel's font family and font style menus start with Apply variable (string variables), and a bound one shows its variable in place of the menu; a colour variable bound to a solid paint brings its whole colour, alpha included, once (the paint's opacity restarts at 100% on binding, an opacity set while bound multiplies on top; unbinding moves the alpha back into the opacity) |
| Explicit modes per frame, library collections' modes | done | stored variable and collection references resolve by exact id in export, library copies and swaps, imported aliases, alias deletion and cycle checks, Dev Inspect, the library mode picker and prototype playback; a collection or variable named like another's id no longer stands in for it there |
| Explicit modes per frame and per page (Page section), 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 | this file's collections (Extend collection on a collection's row; following its edits at once, chains included) and 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) |
@@ -83,6 +83,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
|---|---|---|
| 2026-11-07 | minor | M3, #2084: a group's context menu in the Local variables window has Duplicate group (after Rename group, before Delete group). It adds a group named after the original plus " 2" (the next free number when taken: " 3"), whose variables are copies of the originals with the same leaf names, values in every mode, description, scopes, hidden-from-publishing and code syntax, each sent as `variable.duplicate` plus a rename in one `command.batch` labelled Duplicate group, so one undo step. Measured in the incumbent with one mode; an alias to a variable of the same group keeps pointing at the original. Ungroup is not built. Tests: `variables_select_tests` (headless UI). Grade unchanged |
| 2026-11-07 | minor | M3, #2082: Edit variables in the context menu of a selection of several local variables opens the reference's compact editor with two tabs: Details holds only Hide from publishing, Scope the scopes every selected variable's type has (All supported properties and one box per scope; a selection that shares none says so). Each box is checked when all selected variables have it, empty when none, and shows a dash when they differ; a click sets it on all (or clears it when checked on all), each change one `command.batch`, so one undo step. Names, descriptions and code syntax stay single-variable, as in the reference. The Scope: menu entries stay. A tri-state `widgets::checkbox_tri` draws the dash. Tests: `variables_select_tests` (headless UI). Grade unchanged |
| 2026-11-12 | minor | M7, #2215: pages take a variable mode: with nothing selected the Design panel's Page section shows a Variable modes section, Auto or a mode of each collection that has more than one, as the help center says ("Apply variable mode" in the Page section of the right sidebar) (#2215). The same section the frames' Variable modes use (`variable.setMode` without layers sets the page's mode), one undo step per pick. Binding the page background to a variable is not in the reference and is not offered |
| 2026-11-10 | minor | M7, #2174: a text style binds a number variable to its weight (`style.bind {field: "fontWeight"}`): the style takes the face nearest the number and sets the `wght` axis, so static and variable fonts both follow; a face-name (string) binding and a weight binding replace each other, and the weight wins in a file carrying both. The style editor's weight menu offers number variables beside string ones. A number bound to a line height is applied in pixels, as the reference tool does, which a test now pins. Numbers unchanged. Tests: `style_vars_tests`, `style_picker::tests`. |
| 2026-11-07 | patch | M3, #2088: with two or more layers selected the Design panel header's component button opens a menu with Create component, Create multiple components and Create component set (`component.createSet`, naming measured in Figma), as Figma's does; one layer keeps the plain button. Tests: `create_multiple_menu_tests`, `components_behaviour_tests::create_multiple_components_keeps_names_and_places_and_is_one_undo_step`, `components_behaviour_tests::create_set_*`. Grade unchanged. |
| 2026-11-05 | minor | M3, #2002: the Local variables window selects several variables, as the Layers panel selects layers. A click selects one row, Shift+click the range from the anchor through the rows shown, Cmd/Ctrl+click toggles a row, Escape or a click on the empty space below the rows clears; selected rows use the sidebar's selected-row colour. Right-clicking a row that is part of a selection of several offers Delete, Move to group and Scope (all supported properties, hidden from pickers, or only one scope) for the selected local variables, each sent as one `command.batch`, so one undo step; no engine command changed. Renaming and value edits stay single-row. Tests: `variables_select_tests` (headless UI). Grade unchanged |
+4 -4
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (`.fig` import and export read and write a boolean group's corner radius and smoothing (#2222); `.jam` import: stickies come in as native board stickies (#1808); `.fig` import scales per-point vector corner radii with the row that supplies their network and keeps different instance scales separate in the network cache (#2097); `.fig` export preserves scaled-instance lengths reset to their unscaled main values, including nested linked-style overrides (#2097); `.fig` import: the layers of an instance scaled with the Scale tool draw at its scale factor, not at their main's lengths (#2091); a color profile per PNG, JPG and WebP export setting (Same as file, sRGB or Display P3), the pixels converted; a `.jam` connector end with an `AUTO` or `CENTER` magnet leaves the side facing the other end, not the side its stale saved point named (#1909); a FigJam elbow connector saved with its path (`connectorControlPoints`) keeps it on import, as a board connector reshaped by hand does, instead of being routed again (#1910); JPG exports take a quality, 1 to 100 with 90 the default, and embed the file's colour profile when it is sRGB too (#2004); a FigJam connector without a saved snapshot took no stroke and was invisible; its line's paint, weight, cap and dashes now come from the file's generated line record (#1807); a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); imported and pasted text finds an installed family under the other names its fonts give it, and with other case and spacing (#1292); `.fig` export writes colour calculations as the colour they show in each mode and leaves out, with a warning, one that resolves to nothing (#1280); `.fig` import budgets four decoded values per compressed byte instead of one, still within 20M–128M, so dense files within that budget open instead of being refused as having too many values (#574); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; folders, devices and pipes are refused before anything is read (#1114); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); a FigJam `.jam` file opens as a board (#1342); FigJam `.jam` files open like `.fig` files: File ▸ Open, drag and drop, `file.import` and the command line (#1342); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (`.fig` export writes list spacing, hanging punctuation and lists, and vertical trim (#2305); `.fig` import and export read and write a boolean group's corner radius and smoothing (#2222); `.jam` import: stickies come in as native board stickies (#1808); `.fig` import scales per-point vector corner radii with the row that supplies their network and keeps different instance scales separate in the network cache (#2097); `.fig` export preserves scaled-instance lengths reset to their unscaled main values, including nested linked-style overrides (#2097); `.fig` import: the layers of an instance scaled with the Scale tool draw at its scale factor, not at their main's lengths (#2091); a color profile per PNG, JPG and WebP export setting (Same as file, sRGB or Display P3), the pixels converted; a `.jam` connector end with an `AUTO` or `CENTER` magnet leaves the side facing the other end, not the side its stale saved point named (#1909); a FigJam elbow connector saved with its path (`connectorControlPoints`) keeps it on import, as a board connector reshaped by hand does, instead of being routed again (#1910); JPG exports take a quality, 1 to 100 with 90 the default, and embed the file's colour profile when it is sRGB too (#2004); a FigJam connector without a saved snapshot took no stroke and was invisible; its line's paint, weight, cap and dashes now come from the file's generated line record (#1807); a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); imported and pasted text finds an installed family under the other names its fonts give it, and with other case and spacing (#1292); `.fig` export writes colour calculations as the colour they show in each mode and leaves out, with a warning, one that resolves to nothing (#1280); `.fig` import budgets four decoded values per compressed byte instead of one, still within 20M–128M, so dense files within that budget open instead of being refused as having too many values (#574); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; folders, devices and pipes are refused before anything is read (#1114); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); a FigJam `.jam` file opens as a board (#1342); FigJam `.jam` files open like `.fig` files: File ▸ Open, drag and drop, `file.import` and the command line (#1342); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.Line truncated
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
@@ -106,7 +106,7 @@ scales and empty-style clears; they have not been checked in other software.
(font, with styles under the names font families give their faces: "SemiBold", "ExtraBold"…, and
"Semi Bold" in Inter; size; line height, a share of
the font size as a raw multiplier; letter spacing, case, decoration, links), alignment, auto resize,
truncation, paragraph spacing and indent, list types and styled runs with their fills (indexed
truncation, paragraph spacing and indent, list spacing, hanging punctuation and hanging lists, vertical trim, list types and styled runs with their fills (indexed
by UTF-16 unit); components (description, boolean, text and instance swap properties and the
layers bound to them, with their values also as variable data), component sets (the set holds
the definitions, variant properties included with their values in order; each variant has its
@@ -141,8 +141,7 @@ scales and empty-style clears; they have not been checked in other software.
with the width on both sides when the shape has a fill too), pattern fills, WebP export settings (written as PNG),
image adjustments, glass settings other than frost, springs other than Gentle (written as ease
out), set-variable, set-variable-mode and conditional actions, scroll-to offsets, text decoration
looks, variable font axes, text on a path, nested list levels, list spacing, hanging punctuation,
leading trim, OpenType features, per-layer operations inside boolean groups, different stroke ends
looks, variable font axes, text on a path, nested list levels, OpenType features, per-layer operations inside boolean groups, different stroke ends
on lines, the Dash cap (no public name for it). Not written at all: stroke outlines of dashed and per-side strokes and of inside or
outside strokes on polygons, stars and vectors, glyph outlines of text (a reader lays text out
with its own fonts), version history and branches.
@@ -196,6 +195,7 @@ scales and empty-style clears; they have not been checked in other software.
| Date | Change | Summary |
|---|---|---|
| 2026-11-12 | minor | M6, #2305: the `.fig` export writes a text's list spacing (`listSpacing`), hanging punctuation (`hangingPunctuation`), hanging lists (`hangingList`) and vertical trim (`leadingTrim`: none or cap height), which the importer already read. A field is written only when it differs from its default (in a design file saved by Figma, a text at the defaults carries none of the four), and an instance override that goes back to the default clears it. The warning narrows to OpenType features, which the importer does not read from a `.fig`. Checked through the importer only, not in other software. |
| 2026-11-10 | minor | M4, #2222: a boolean group's corner radius and smoothing are read and written by the `.fig` importer and exporter, and by the REST importer |
| 2026-11-07 | patch | Fonts, #2092: a font loaded with `text.loadFont` is the counterpart of a font uploaded to the organization, which Figma's help center ("Manage conflicting fonts") ranks first, ahead of installed fonts and the web fonts. FigCraft chose the first face added for a family and style, so the built-in Inter 4.x kept Regular, SemiBold and Bold after Inter 3.19 was loaded and only Medium refitted. A loaded face now wins over the other faces of its family and style, the latest load over earlier ones; style matching and fallbacks are unchanged, and `refitted` lists the text whose size changes. Installed versus built-in order is not changed here. Test: `fontdb::tests::a_loaded_face_wins_over_the_built_in_one_of_its_family_and_style`. Numbers unchanged. |
| 2026-11-08 | minor | M6, #2097 follow-up: per-point vector corner radii take the scale of the row supplying `vectorData`, including nested instance rows; override and derived rows are not scaled twice. Network caching distinguishes the instance scale even when resizing gives two vectors the same final box. Synthetic tests cover missing/unit factors, half/double scale, derived size alone, overridden and derived vector data, nested scale and equal-size cache separation. Layer-radius fallback remains the separate #2115 change. Numbers unchanged. |
+189
View File
@@ -0,0 +1,189 @@
# Interaction measurements
> **Last reviewed:** 2026-11-08 · **Last updated:** 2026-11-08 · **Change:** minor (first record of the interactions measured against Figma, case by case) · **Target:** Figma Design web app, measured 2026-11-08
On 2026-11-08 a set of interactions was measured in Figma's web app and compared with FigCraft's: modifier keys on move, resize and rotate, arrow keys, zoom steps, auto layout Fill and Hug, instances, components, number field scrubbing, sidebar widths, auto-pan at the viewport edge, copy and paste in vector edit mode, duplicate and paste placement, and text-editing keys. Every measurement used synthetic files, driving the UI and reading the Design and Layers panels (field values, dropdown labels, bounding boxes), or counting pixels on screen where a panel could not show the number. No incumbent screenshots are committed. This complements [ui-parity.md](ui-parity.md) and works on gap 4 in [gaps.md](gaps.md), "Interaction fidelity not compared tool by tool". It is not a full pass: each section lists what was covered, and the open questions at the end list what could not be measured.
Status values: **matches** (FigCraft already behaved as measured), **fixed in #NNNN (open)** (a mismatch found, with the pull request that fixes it, open when this was written; the pull request carries its tests and CI), **partly fixed in #NNNN (open)** (the same, with what is still missing named), **not measured** (with the reason), **inconclusive**. Error margins are given where the measurement had one. Trust the shapes of the rules more than the last digit of a number.
## Move, resize and rotate modifiers
Synthetic pointer sequences on the canvas with modifier flags set. Resize handles were pressed 4 px inside the edge, so absolute resize numbers are about 8 px short per side; the cases were read relatively.
| Behaviour | Figma (measured) | FigCraft | Status |
|---|---|---|---|
| Shift while moving | locks to the dominant axis (drags of +100,+30 and +100,+90 both gave dy 0); no 45 degree snap | the smaller of dx and dy is zeroed | matches |
| Alt while moving | a copy stays at the original place and the selection moves (layers 1 to 2) | duplicates on drag, re-synced with the Alt key | matches |
| Ctrl while moving | inconclusive: with Ctrl from the start the layer did not move, with Ctrl late in the drag it still snapped; a synthetic Ctrl does not seem to reach Figma as the modifier | Ctrl or Cmd turns snapping off | not measured (synthetic Ctrl is not delivered as a modifier) |
| Shift while resizing | corner (+50,+10) on a 100 by 50 layer keeps the ratio; right edge (+50,0) scales the height too, about the vertical centre | corner by the dominant factor about the opposite corner; edge about the opposite edge's middle | matches |
| Alt while resizing | a right edge grows symmetrically, the centre kept | resizes from the centre | matches |
| Shift and Alt on a corner | proportional, centre kept | both | matches |
| Modifier changed mid-drag | Shift on the second half locks; Shift on then off unlocks again; read fresh on every move | modifiers read on each move | matches |
| Shift while rotating | snaps the absolute angle to multiples of 15 | snapped the change in angle | fixed in #2096 (open) |
| Space while drawing or marquee | not measurable through the browser | not compared | not measured (the browser does not deliver it) |
## Nudge and arrow pan
Real keys; pan distances read from screenshots, plus or minus 3 px.
| Behaviour | Figma (measured) | FigCraft | Status |
|---|---|---|---|
| Arrow keys on a selection | nudge 1 document px, 10 with Shift, at any zoom | same | matches |
| Arrow keys with nothing selected | pan the view: Right about 62 to 64 px (two trials), Shift+Right about 320 px (5 times) | 20 and 200 px (`PAN_STEP_PX`, 10 times) | fixed in #2106 (open) |
## Zoom steps and fit
Real keys. The canvas was 1389 by 903 CSS px with the floating bottom toolbar over it.
| Behaviour | Figma (measured) | FigCraft | Status |
|---|---|---|---|
| Zoom in from 100% | 200, 400, 800, 1600, 3200, 6400, 12800, 25600 (maximum) | same steps (`STEPS` in `cmd/view.rs`) | matches |
| Zoom out from 100% | 50, 25, 13, 6, 3, 2 (minimum) | same | matches |
| Zoom from a typed 150% | in gives 200, out gives 100 (snaps to the list) | same | matches |
| Zoom to fit (Shift+1) | a 100 by 100 layer at 793% (vertical margin about 55 px); a 1000 by 200 layer at 134% (horizontal margin about 24 px each side); not a fixed 64 px margin, and not separated from the toolbar overlay | not changed | inconclusive (the toolbar overlay mixes into the margins) |
| Zoom to selection (Shift+2) | a 10 by 10 layer at 7630%, about 39 px above and about 100 px below (toolbar) | not changed | inconclusive (same overlay) |
## Snapping and menus
Measured earlier the same day; recorded here by pull request only.
| Behaviour | Figma (measured) | FigCraft | Status |
|---|---|---|---|
| Snap threshold | a layer snaps about 4.5 screen px from a guide, at 100% and 200% | snapped at 5 screen px | fixed in #2086 (open) |
| Submenu opening | a submenu opens about 11 ms after its row is hovered; no safe triangle seen | opened after 0.15 s, with a safe triangle | delay fixed in #2093 (open); the safe triangle FigCraft keeps is unresolved (none seen in Figma; asked in #2093) |
## Auto layout: Fill and Hug
Values read from the zoomed Design panel's fields and dropdowns, in horizontal rows hugging height unless noted.
| Behaviour | Figma (measured) | FigCraft | Status |
|---|---|---|---|
| Cross axis: a child set to Fill in a hugging parent | the child stretches to the tallest sibling; the parent stays Hug (child 40 Fill, parent 40 Hug; sibling typed to 60 gives 60 Fill and 60 Hug); no notice | turned the hugging parent Fixed | fixed in #2099 (open) |
| Cross axis: every child set to Fill | when the last non-Fill child is set, toast "Height set to Fixed for Frame 1" and the parent turns Fixed at its current size; typing 80 on the parent gives 80 Fixed with both children 80 Fill | the parent turned Fixed and the child kept its own size | fixed in #2099 (open) |
| Vertical stack hugging width | C (20 by 10) set to W Fill gives C 100 and the parent 100 Hug; D typed to 150 gives C 150, parent 150 Hug; no toast | the parent turned Fixed and the child kept its own size | fixed in #2099 (open) |
| Padding with a stretched child | padding 10 top and bottom, parent H 60 Hug; child H Fill gives 40 (the content box), parent stays 60 Hug | the parent turned Fixed and the child kept its own size | fixed in #2099 (open) |
| Min height on a stretched child | min height 50 gives child 50 Fill and parent 50 Hug (the stretched child's minimum counts toward the hug size) | the parent turned Fixed and the child kept its own size | fixed in #2099 (open) |
| Text sibling | rect H Fill beside two lines of text (Inter 24, line height 32): text 64 Hug, rect 64 Fill, parent 64 Hug; a third line gives 96, 96, 96 Hug | the parent turned Fixed and the child kept its own size | fixed in #2099 (open) |
| Main axis: a Fill child in a hugging parent | the parent turns Fixed (toast "Width set to Fixed for Frame 1", parent 197 Fixed) | the parent becomes fixed | matches |
| Wrap on, Fill height on a child | "Fill container" for height is greyed out on a child of a wrapping row (checked only with the parent hugging height) | not compared | not measured (only the hugging case was checked) |
| Setting a parent to Hug while a child fills along it | the child turns Fixed with the toast `Width set to Fixed for Rectangle 1` (the child's name) | demoted the child silently | fixed in #2099 (open) |
## Instances
| Behaviour | Figma (measured) | FigCraft | Status |
|---|---|---|---|
| Main component width and height follow its content | main: horizontal auto layout, W Hug, H Hug (135 by 49); a pasted instance reads the same | instance root sizing did not follow its main | fixed in #2099 (open) |
| A child set to Fill along the main axis | toast "Width set to Fixed for Frame 1"; main W Fixed 135, H Hug; the plain instance turns W Fixed 135 too, H Hug | the instance's own resizing never followed its main | fixed in #2099 (open) |
| Instance with its width overridden to 200 | stays W 200 Fixed through the main's change, H keeps following (Hug) | the instance's own resizing never followed its main | fixed in #2099 (open) |
| Main set back to Hug contents | toast "Width set to Fixed for Rectangle 1"; main 135 Hug; the plain instance back to 135 Hug; the overridden one stays 200 Fixed | the instance's own resizing never followed its main | fixed in #2099 (open) |
| Undo after the Fill change | unreliable in the web app (reverted the child, left the main Fixed) | not compared | inconclusive (not used as evidence) |
## Components
Third entry of the sidebar header's component dropdown, several layers selected.
| Behaviour | Figma (measured) | FigCraft | Status |
|---|---|---|---|
| Create component set on frames named "Primary", "Secondary", "Ghost" | one set named "Component 1"; variants show their bare names; one property "Property 1" with the names as values, in canvas order left to right | no Create component set | fixed in #2095 (open) |
| Frames named "Size=Small", "Size=Large", "Size=Medium" | set "Component 1"; property "Size", values parsed from the names (Layers shows "Small", "Large", "Medium") | no Create component set | fixed in #2095 (open) |
| Three frames all named "Card" | set named "Card"; property "Property 1", every variant "Default"; the panel warns that some variants share property values, with "Select conflicting variants" | no Create component set | partly fixed in #2095 (open): the set and its names; the duplicate-value warning and Select conflicting variants are not added |
| Layout of the new set | no auto layout; the frames stay where they were; the set (purple dashed frame, stroke #8A38F5 inside 1 px, corner radius 5) wraps them with 20 px on every side; the set is selected afterwards; one undo reverts everything | no Create component set | fixed in #2095 (open) |
| Wrap style, caret in one paragraph | Balance re-wraps only that paragraph; with the layer selected the control shows "Mixed" | a wrap style per layer only | fixed in #2094 (open) |
| Wrap style set on the whole layer | Pretty overwrites the paragraph's Balance; editing again, both paragraphs show Pretty | a wrap style per layer only | fixed in #2094 (open) |
| Text style carrying the wrap style | not checked | not compared | not measured (not checked) |
## Number field scrubbing
Real drags on the field's label; values read from the input.
| Behaviour | Figma (measured) | FigCraft | Status |
|---|---|---|---|
| X from 100 | a 12.2 CSS px drag right gives 106; a 61.2 CSS px drag gives 131; about 0.5 per CSS px, whole numbers | 1 per point, with a speed factor by height | fixed in #2102 (open) |
| Rotation | a 12.2 CSS px drag right on its icon gave -6 degrees (one clean run; direction to confirm) | 1 per point | fixed in #2102 (open) |
| Opacity from 100% | 12.2 CSS px left gives 95% (about 0.4 per px); clamped at 100 going right | 1 per point | fixed in #2102 (open) |
| Shift and Alt rates | synthetic events did not reach the label's handlers | ten times with Shift, a tenth with Alt | not measured (not deliverable through the browser) |
| Pointer lock | not measured | not compared | not measured |
## Sidebars
Read with `getBoundingClientRect` at a 1920 CSS px viewport, device pixel ratio 1.
| Behaviour | Figma (measured) | FigCraft | Status |
|---|---|---|---|
| Left sidebar | default 240, minimum 240, maximum about 450 (451 with the border) | 200 to 480 | fixed in #2103 (open) |
| Right sidebar | default 240 (241 with the border), minimum 240, maximum 500 | 240 to 480 | fixed in #2103 (open) |
| Edge | a double-click resets to the default; the hit zone is a couple of px wide | double-click resets | matches |
## Auto-pan at the viewport edge
Synthetic held drags at 100% zoom, pan speeds read from timed holds (0.5, 1.0, 1.5 and 3.0 s, linear in time).
| Behaviour | Figma (measured) | FigCraft | Status |
|---|---|---|---|
| Moving a layer with the pointer near an edge | 40, 25 and 15 px inside: no pan; 12 px inside about 120 px/s; 8 px about 275 px/s; 2 px 520 to 530 px/s; 30 px outside about 600 px/s | no auto-pan for any drag | fixed in #2104 (open) |
| Fit of the speed | speed = clamp(40 x (15 - d), 0, 600) screen px per second, d = distance inside the edge (negative outside); zone about 14 to 15 px; cap about 600 px/s; no acceleration | n/a | fixed in #2104 (open) |
| Marquee, drawing a rectangle, dragging a prototype connection | the same pan (bottom, left and right edges tested) | none | fixed in #2104 (open) |
| Resize, rotate and other gestures | not measured | not compared | not measured |
| Speed at other zooms | not measured (taken as screen px per second) | not compared | not measured |
## Vector edit clipboard
Real keys and clicks, an open pen path A-B-C-D (three segments), vector edit mode entered on it.
| Behaviour | Figma (measured) | FigCraft | Status |
|---|---|---|---|
| Copy a segment, leave edit mode, paste | a new layer holding only that segment, in place, sized to it | copied the whole layer | fixed in #2105 (open) |
| Copy one point, paste | a new layer holding the segments touching the point, in place | copied the whole layer | fixed in #2105 (open) |
| Cut a segment | removed from the network; the path splits into two subpaths in the same layer; edit mode stays; pasting outside gives a new layer with the cut segment, in place | deleted the segment without copying it | fixed in #2105 (open) |
| Paste while editing the same vector | no new layer; a duplicate of the segment is added to the network in place; the pasted points are selected | did nothing | fixed in #2105 (open) |
| Paste a copied rectangle while editing a vector | the rectangle's outline joins the network in place (four corners selected); no new layer; the bounds grow to include it | did nothing | fixed in #2105 (open) |
A click on a segment's middle can add a point there in Figma's edit mode (seen once); it did not change the rules above.
## Duplicate and paste
| Behaviour | Figma (measured) | FigCraft | Status |
|---|---|---|---|
| Ctrl+D | a copy in place, selected, just above | same | matches |
| Move the copy +100, Ctrl+D | repeats +100 (smart duplicate); a reselect forgets the step | same | matches |
| Alt-drag +100, Ctrl+D | repeats (+200 in total) | same | matches |
| Paste with the original in view | in place on it, selected, just above | same | matches |
| Paste with the original off screen | at the view's centre | same | matches |
| Paste with a frame selected (far away) | inside the frame, at its centre | same | matches |
| Tests for the three paste placement rules | the three rules above | already as measured; no test pinned them | matches (pinned by tests in #2108, open) |
| Ctrl+Shift+V (paste over) | not measured (chord blocked or slow) | paste over at the selection's top-left | not measured (chord blocked or slow) |
## Selection keys
| Behaviour | Figma (measured) | FigCraft | Status |
|---|---|---|---|
| Escape with a nested layer selected | one Escape selects nothing, whether the layer was selected on the canvas, from the Layers panel or by Tab | selected the parent first | fixed in #2107 (open) |
## Text-editing keys
Defaults for a new text: Inter Regular 12, line height Auto, letter spacing 0%.
| Behaviour | Figma (measured) | FigCraft | Status |
|---|---|---|---|
| Ctrl+Shift+> and < | font size +1 and -1 (12 to 18 stepped) | step 1 | matches |
| Alt+Shift+> and < | line height +1 and -1 px; from Auto at 12 it starts about 15 (not exact) | from the resolved height, 1 px | matches |
| Alt+> and < | letter spacing +1% and -1% | 1% | matches |
| Ctrl+B, I, U on a selected layer | toggles bold, italic, underline | same | matches |
| Double-click a word while editing | selects the word | same | matches |
| Triple-click | selects the line (paragraph) | same | matches |
| Escape while editing | leaves editing, the layer stays selected | same | matches |
| Ctrl+A while editing | selects all the text | same | matches |
## Open questions
| Behaviour | Figma (measured) | FigCraft | Status |
|---|---|---|---|
| Add auto layout (Shift+A) defaults | two 40 by 40 layers side by side: horizontal, gap "Auto", padding 0, W Fixed (140), H Hug (40); the same with a Y offset of 30 drops the offset; stacked: vertical, gap 60 (numeric), W Fixed (40), H Hug (140); a single 40 by 40 layer: new frame, W and H Hug (60), padding 10, gap 10; an existing 300 by 200 frame with two layers: W stays Fixed (300), H becomes Hug (149), gap "Auto", padding 49 | Hug on both axes for a wrapped selection and an inferred gap | inconclusive (inconsistent between cases in Figma itself: the gap is "Auto" in one case and numeric in another, the cross axis Fixed in the vertical case; the inference has more inputs than measured) |
Also open, as listed in their sections: the zoom-to-fit margins (zoom section, inconclusive because of the toolbar overlay), Space while drawing or dragging a selection box and Ctrl while moving (modifiers section, not measurable from the browser), and the Shift and Alt scrub rates (number field section, not measurable).
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-11-08 | minor | Created from the 2026-11-08 black-box comparison of FigCraft's interactions with Figma's, with synthetic files and no committed incumbent screenshots |
+3 -1
View File
@@ -1732,7 +1732,9 @@ permissions: without it nothing is downloaded); `plugin.directory.publish {path
`--radius-*`, `--text-*`, `--spacing-*`, `--font-*`; a number's namespace and unit come from what
layers bind it to), other modes on `[data-<collection>="<mode>"]`. With `mobile: <frame id>` (the
same layer names, mobile layout) the code is mobile first: the mobile frame's classes, the
desktop frame's that differ behind `lg:`, and layers only on desktop `hidden` plus `lg:flex`
desktop frame's that differ behind `lg:` (a mobile `max-width` is dropped as the phone's own width,
unless desktop has the same cap: a fixed-size layer such as a badge keeps `max-w-[48px]` at every
width), and layers only on desktop `hidden` plus `lg:flex`
(auto layout frames) or `lg:block` (other layers).
- **Component code:** `inspect.code {id, language: react|leptos|vue|swiftui|compose|arkui|flutter}` on a component,
a component set or a variant writes the component as a reusable unit: React (TSX) with a props
+5 -6
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (the text caret takes the text's solid fill colour (#2156); the Rotation field shows and sets the rotation on the page, in frames and groups, through node.set absoluteRotation (#2196); the eyedropper picks a magnified image's pixel under the pointer, not a blend with its neighbours (#2189); Selection colors shows from one distinct colour when a selected layer has children, and from two otherwise, measured in Figma's web app on 2026-11-09 on a frame, a group and plain rectangles; the crosshair of the shape and Text tools is drawn dark with a white edge, so it shows on a white frame whatever the system's pointer style (#2198); a boolean group takes a corner radius (and smoothing) that rounds every sharp corner of its result (#2222); the Point section's X and Y in vector edit mode show Mixed and set every selected point of the layer being edited, as in Figma; Preferences ▸ Invert zoom direction reverses wheel zoom, pinch unchanged (#2145); Shift while rotating lands on absolute multiples of 15°, measured in Figma; Flatten bakes a vector's point corner radius into its path (#2195); a click on a fill, stroke or effect row's grip or background highlights it, Cmd/Ctrl+C copies that paint or effect and Cmd/Ctrl+V pastes it onto the selected layers, as the help center's Copy and paste properties between layers says (#1494); wrap style per paragraph and in text styles (#1827); the Annotation tool (Shift+T, `tool.annotation {id?}`): with a layer selected (or an `id`) it asks the UI for that layer's note at once, and in the tool a press on a layer selects it and asks for its note, a press on empty canvas does nothing; the marquee follows Figma's measured rules: a partly covered frame or section gives the layers in it the marquee touches, a rotated shape is touched by its outline, a drag from inside a frame selects that frame only when the marquee also takes another top-level frame (then whole) (`marquee_behaviour_tests`); copy, cut and paste in vector edit mode act on the selected points and segments (#1875); M8, #2146: K focuses the Scale tool's factor field with its text selected, so K, 2, Enter scales the selection by two about the panel's origin (FigCraft's starts at the top left; Figma's grid starts at the centre, left for a follow-up because the origin also sets where a handle drag pivots, which is not measured) in one undo step, after which the tool returns to Move with the selection kept; Escape in the field closes it with the tool back on Move and nothing changed (measured in Figma's web app, 2026-11-09; `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two`, `escape_in_the_scale_field_closes_it_and_changes_nothing`); the main menu and its submenus stay inside the window, moving up to fit and scrolling when taller (#2217); locked layers ignore the arrow keys and a locked parent's children can't be locked on their own (#1560); the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open thLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (M8: Tab while renaming the last layer wraps to the first row, measured in Figma's web app on 2026-11-12; the text caret takes the text's solid fill colour (#2156); the Rotation field shows and sets the rotation on the page, in frames and groups, through node.set absoluteRotation (#2196); the eyedropper picks a magnified image's pixel under the pointer, not a blend with its neighbours (#2189); Selection colors shows from one distinct colour when a selected layer has children, and from two otherwise, measured in Figma's web app on 2026-11-09 on a frame, a group and plain rectangles; the crosshair of the shape and Text tools is drawn dark with a white edge, so it shows on a white frame whatever the system's pointer style (#2198); a boolean group takes a corner radius (and smoothing) that rounds every sharp corner of its result (#2222); the Point section's X and Y in vector edit mode show Mixed and set every selected point of the layer being edited, as in Figma; Preferences ▸ Invert zoom direction reverses wheel zoom, pinch unchanged (#2145); Shift while rotating lands on absolute multiples of 15°, measured in Figma; Flatten bakes a vector's point corner radius into its path (#2195); a click on a fill, stroke or effect row's grip or background highlights it, Cmd/Ctrl+C copies that paint or effect and Cmd/Ctrl+V pastes it onto the selected layers, as the help center's Copy and paste properties between layers says (#1494); wrap style per paragraph and in text styles (#1827); the Annotation tool (Shift+T, `tool.annotation {id?}`): with a layer selected (or an `id`) it asks the UI for that layer's note at once, and in the tool a press on a layer selects it and asks for its note, a press on empty canvas does nothing; the marquee follows Figma's measured rules: a partly covered frame or section gives the layers in it the marquee touches, a rotated shape is touched by its outline, a drag from inside a frame selects that frame only when the marquee also takes another top-level frame (then whole) (`marquee_behaviour_tests`); copy, cut and paste in vector edit mode act on the selected points and segments (#1875); M8, #2146: K focuses the Scale tool's factor field with its text selected, so K, 2, Enter scales the selection by two about the panel's origin (FigCraft's starts at the top left; Figma's grid starts at the centre, left for a follow-up because the origin also sets where a handle drag pivots, which is not measured) in one undo step, after which the tool returns to Move with the selection kept; Escape in the field closes it with the tool back on Move and nothing changed (measured in Figma's web app, 2026-11-09; `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two`, `escape_in_the_scale_field_closes_it_and_changes_nothing`); the main menu and its submenus stay inside the window, moving up to fit and scrolling when taller (#2217); locked layers ignore the arrow keys and a locked parent's children can't be locked on their own (#1560); the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the resuLine truncated
Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys,
drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status:
@@ -9,7 +9,7 @@ Nothing here has been compared tool by tool against the running Figma yet; that
remaining work (40–60 h, [gaps](gaps.md) #4).
**Summary (estimated):** UI/UX fidelity ~58%. Layout and look of UI3 compared side by side (~65%);
interaction feel not yet compared.
interaction feel only partly compared: the interactions measured so far, case by case, are in [interaction-measurements.md](interaction-measurements.md).
## Window and panels
@@ -49,7 +49,7 @@ interaction feel not yet compared.
| Expand and collapse: Alt-click opens or closes everything inside; Collapse layers (Alt+L) keeps the way to the selection open | done | Alt-click pinned (the documentation is silent) |
| Drag to reorder and into frames, groups and auto layout: insertion line, spring-loaded open, several layers together, Escape cancels | done | several layers dropped together land together (they split apart before); instances and a layer's own children refuse drops |
| Eye and padlock: click, or drag across rows to set several in one step; layers in a locked frame show a faint padlock | done | the toggles no longer vanish under the pointer (clicks on them could miss) (a child's padlock can't be toggled while its parent is locked) |
| Rename: double click, F2 / Cmd+R, Tab to the next row; several layers open Rename layers (match, regular expressions, current name and number codes, start value, preview) | done | numbered in stacking order, pinned |
| Rename: double click, F2 / Cmd+R, Tab to the next row; several layers open Rename layers (match, regular expressions, current name and number codes, start value, preview) | done | numbered in stacking order, pinned; Tab on the last row wraps to the first row's rename, measured in Figma's web app on 2026-11-12 (`tab_on_the_last_row_commits_and_wraps_to_the_first`); Shift+Tab on the first row ends the rename (not measured); Tab and Shift+Tab move only the rename, the selection stays on the layer first renamed, measured in Figma's web app on 2026-11-12 (`tab_while_renaming_leaves_the_selection_on_the_first_layer`) |
| Find layers (filter by name) | done | not covered by the documentation |
| Pages: add, rename, drag to reorder, duplicate ("Copy of"), delete, dividers (a blank page named with a hyphen) | done | the line between Pages and Layers drags to size the pages list, kept in the preferences; a long list scrolls in its own area and a page dropped outside the visible list stays put (#272) (`layers_behaviour_tests`) |
| Assets panel: folders from page, frames and slash names (fold away), search, thumbnails, drag onto the canvas or into frames and auto layout, Alt-drop swap, enabled libraries | done | Shift+I (Quick insert), Alt+2 and View ▸ Panels ▸ Assets open it; the toolbar's Resources button was removed (the left rail's Assets tab and those shortcuts are the ways in) |
@@ -97,9 +97,7 @@ interaction feel not yet compared.
| Rectangle, ellipse (arc handles and Design panel arc fields), polygon, star, line, arrow; Shift/Alt | done | Alt draws lines from the centre too (`shapes_behaviour_tests`) |
| Corner radius handles (inside each corner of a rectangle or frame, a polygon's or star's top point; Alt: one corner), star ratio handle; Count and Ratio fields | done | handles hide under 48 px on screen; no count handle on the canvas (the Count field instead); corner smoothing rounds rectangles and frames only |
| Pen: vector networks, branch, close, bend; pencil | done | pen pressure on Windows and from browser styluses only; outside edit mode the Pen closes on its first point, connects to its other points and continues a selected open path from an end point (`vector_multi_behaviour_tests`); Escape ends the path and keeps its layer in edit mode with the pen, so the next path joins the same vector and another Escape leaves edit mode (#760, `pen_end_tests`); Enter or a double-click ends the path (at the double-click's point, added once) and edits the vector with the Move tool, a double-click with the idle pen edits the layer under it, and in edit mode the pen's Enter or double-click takes the Move tool (#1430, `pen_vector_edit_tests`); switching from Design to Prototype or Inspect ends the path and returns to Move (#308) |
| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); entering edit mode and picking a region keep the canvas still (#322); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`); a Bend click on a sharp point between two segments curves it (handles along the line between its neighbours, a third of the way to each, `ops::auto_smooth`), on a curved one sharpens it, a drag sets the handles by hand (#1875); with regions selected, the Fill section (its rows and the colour picker) and `fill.set`, `fill.add`, `fill.remove`, `fill.move` and `fill.paste` edit those regions' own fills, the layer's untouched, or the regions named by `regions: [index, ...]`; Copy fill takes the region's paint, the colour picker and its canvas gradient and crop handles edit it (an image from Upload from computer included; a file dropped on the picker goes to the canvas instead), what belongs to the layer (its fill style, a variable bound to its fill, the image crop button) stays off a region's rows, and `fill.pattern` sets the layer's fill (#1875); the Point section's X and Y show the value the selected points share, or Mixed, and a typed value sets every selected point's own coordinate, in the layer being edited (measured in Figma on 2026-11-08). Not yet: areas of other layers edited at once; the Point section's X and Y over points of several edited layers |
| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); entering edit mode and picking a region keep the canvas still (#322); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377), and bakes a vector's point corner radius into its path (each rounded point becomes the arc's end points joined by curves, the layer stays a vector and its box fits the real geometry, #2195); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`); a Bend click on a sharp point between two segments curves it (handles along the line between its neighbours, a third of the way to each, `ops::auto_smooth`), on a curved one sharpens it, a drag sets the handles by hand (#1875); with regions selected, the Fill section (its rows and the colour picker) and `fill.set`, `fill.add`, `fill.remove`, `fill.move` and `fill.paste` edit those regions' own fills, the layer's untouched, or the regions named by `regions: [index, ...]`; Copy fill takes the region's paint, the colour picker and its canvas gradient and crop handles edit it (an image from Upload from computer included; a file dropped on the picker goes to the canvas instead), what belongs to the layer (its fill style, a variable bound to its fill, the image crop button) stays off a region's rows, and `fill.pattern` sets the layer's fill (#1875). Not yet: areas of other layers edited at once |
| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); entering edit mode and picking a region keep the canvas still (#322); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`); a Bend click on a sharp point between two segments curves it (handles along the line between its neighbours, a third of the way to each, `ops::auto_smooth`), on a curved one sharpens it, a drag sets the handles by hand (#1875); with regions selected, the Fill section (its rows and the colour picker) and `fill.set`, `fill.add`, `fill.remove`, `fill.move` and `fill.paste` edit those regions' own fills, the layer's untouched, or the regions named by `regions: [index, ...]`; Copy fill takes the region's paint, the colour picker and its canvas gradient and crop handles edit it (an image from Upload from computer included; a file dropped on the picker goes to the canvas instead), what belongs to the layer (its fill style, a variable bound to its fill, the image crop button) stays off a region's rows, and `fill.pattern` sets the layer's fill (#1875); Copy and Cut with points or segments selected take just those (a lone point: the segments touching it) as a vector with the layer's look, at their own place, so a paste outside edit mode is a new layer in place; Cut removes them as Delete does, the path splitting in the same layer with edit mode staying on; Paste while editing adds the clipboard's segments, or a copied shape's or boolean's outline, to the edited network in place with the new points selected, skipping layers with no outline (text, frames, groups; named in `skipped`) and refusing when none has one, as measured in Figma (`vector_clipboard_tests`, #1875). Not yet: areas of other layers edited at once |
| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); entering edit mode and picking a region keep the canvas still (#322); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377), and bakes a vector's point corner radius into its path (each rounded point becomes the arc's end points joined by curves, the layer stays a vector and its box fits the real geometry, #2195); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`); a Bend click on a sharp point between two segments curves it (handles along the line between its neighbours, a third of the way to each, `ops::auto_smooth`), on a curved one sharpens it, a drag sets the handles by hand (#1875); with regions selected, the Fill section (its rows and the colour picker) and `fill.set`, `fill.add`, `fill.remove`, `fill.move` and `fill.paste` edit those regions' own fills, the layer's untouched, or the regions named by `regions: [index, ...]`; Copy fill takes the region's paint, the colour picker and its canvas gradient and crop handles edit it (an image from Upload from computer included; a file dropped on the picker goes to the canvas instead), what belongs to the layer (its fill style, a variable bound to its fill, the image crop button) stays off a region's rows, and `fill.pattern` sets the layer's fill (#1875); the Point section's X and Y show the value the selected points share, or Mixed, and a typed value sets every selected point's own coordinate, in the layer being edited (measured in Figma on 2026-11-08); Copy and Cut with points or segments selected take just those (a lone point: the segments touching it) as a vector with the layer's look, at their own place, so a paste outside edit mode is a new layer in place; Cut removes them as Delete does, the path splitting in the same layer with edit mode staying on; Paste while editing adds the clipboard's segments, or a copied shape's or boolean's outline, to the edited network in place with the new points selected, skipping layers with no outline (text, frames, groups; named in `skipped`) and refusing when none has one, as measured in Figma (`vector_clipboard_tests`, #1875). Not yet: areas of other layers edited at once; the Point section's X and Y over points of several edited layers |
| Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | |
| Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | a group past the path operations' caps (250 000 segments, 2 million possible crossings, 4 million segments swept over a fold) draws without its result, with a warning, and Flatten refuses it (#1104); the Design panel's Boolean groups menu leads Union, Subtract, Intersect and Exclude with their own icons, two offset squares with the result filled at reduced alpha (#2003) |
| Text: point, auto height, fixed; caret, words, lines, IME | done | Backspace or Delete at the start of a list item takes its marker and keeps its level, the paragraph starting where the item's text did ("delete the counter, but keep the same level of indentation", Create bulleted and numbered lists; `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests`); max lines on auto-width text keep it auto width, hugging up to that many lines and growing with its longest line ("Text resizing is set to auto height or auto width", Explore text properties; `text_behaviour_tests::max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines`); Alt+Enter while editing opens the link at the caret, web and mail links only (`text.openLink`; "press ⌥Option Return/Alt Enter to activate a focused link", Accessibility at Figma; `text_link_tests`); the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653) and Up/Down step through it (#352); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; colour emoji draw from their colour tables (#300); IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; the caret takes the text's solid fill colour, measured in Figma's web app, 2026-11-09; gradient and image fills (not measured) give a black caret on a light surface and a white one on a dark one (the nearest layer around the text whose top visible fill is a solid at least half opaque as drawn, counting layer opacity, else the page; a layer whose top fill is a gradient or an image is passed over), not the interface's text colour, which is white in the dark theme and vanished on the default light page (#2190) (#2156; `caret_tests`); no spell check |
@@ -217,6 +215,7 @@ This audit records candidates for subsequent fixes; their behaviour is unchanged
| Date | Change | Summary |
|---|---|---|
| 2026-11-12 | patch | M8: Tab while renaming the last layer in the Layers panel commits and opens the first row's rename, as measured in Figma's web app on 2026-11-12 (it ended the rename before); Shift+Tab on the first row is unchanged, not measured; Tab and Shift+Tab now move only the rename and leave the selection on the layer first renamed, as measured in Figma's web app on 2026-11-12 (it followed the rename before) (`tab_on_the_last_row_commits_and_wraps_to_the_first`, `tab_while_renaming_leaves_the_selection_on_the_first_layer`), Rename row |
| 2026-11-09 | patch | M3, #2156: the text caret takes the text's solid fill colour (red text, red caret; light grey on a dark frame, light grey, one colour and no inversion), measured in Figma's web app: the fill of the character before the caret (the first one's at the start); FigCraft draws it opaque (how Figma treats a translucent fill was not measured); gradient, image and hidden fills (not measured) give a black caret on a light surface and a white one on a dark one (the nearest layer around the text whose top visible fill is a solid at least half opaque as drawn, counting layer opacity, else the page; a layer whose top fill is a gradient or an image is passed over), not the interface's text colour (white in the dark theme, invisible on the default light page, #2190). Width and height unchanged; collaborators' carets unchanged. Tests: `caret_tests` |
| 2026-11-10 | minor | The Rotation field shows and sets the layer's rotation on the page (its own angle plus its parents' when no ancestor is flipped; with a flipped ancestor FigCraft reads the composed angle, which flips the sign of the layer's own angle, and Figma's behaviour there was not measured), in frames and groups alike, as measured in Figma's web app for rotated, unflipped parents (#2196) |
| 2026-11-09 | patch | M3, #2189: the eyedropper picks the colour the canvas shows. It sampled a 1x1 render without the canvas's magnified-image pixels, so on an image magnified past 2x it blended a 1 px line with its neighbours (#3D444D picked as #343B44 beside #151B23), and the pixel it sampled began at the pointer instead of being centred on it. It now matches the canvas when zoomed in; zoomed out it still samples the full-resolution image, not the smaller copy the canvas may show. Test: `eyedropper_image_tests` |