Merge main into #2055

This commit is contained in:
bflatastic committed 2026-11-07 08:07:48 +07:00
commit 7c3a3b2703
11 files changed
+335 -40

No files matched your search

+4
View File
@@ -104,3 +104,7 @@ real_name = "Oleksii Sukholita"
[people.helenkwok]
real_name = "Helen Kwok"
[people.denisg0mes]
real_name = "Denis Gomes"
display_name = "Denis Gomes"
+9
View File
@@ -1352,6 +1352,15 @@ pub fn library_of(doc: &Document, id: NodeId) -> Option<&str> {
doc.libraries.import_of(root).map(|r| r.library.as_str())
}
pub fn library_source(doc: &Document, id: NodeId) -> Option<(&str, NodeId)> {
if !doc.is_remote(id) {
return None;
}
let record = doc.libraries.import_of(asset_root(doc, id)?)?;
let key = record.ids.iter().find(|(_, local)| **local == id).map_or(record.key, |(key, _)| *key);
Some((record.library.as_str(), key))
}
/// The display name of library `id` in `doc` (its link's name, else the id).
pub fn library_name<'a>(doc: &'a Document, id: &'a str) -> &'a str {
doc.libraries.link(id).map_or(id, |l| l.name.as_str())
+69 -2
View File
@@ -189,8 +189,6 @@ fn assert_remote_read_only(s: &mut Session, b: NodeId, remote_button: NodeId) {
let style = s.doc().unwrap().styles[0].id.clone();
assert!(s.execute("style.update", &json!({"style": style, "name": "Mine"})).is_err());
assert!(s.execute("style.delete", &json!({"style": style})).is_err());
let e = s.execute("component.goToMain", &json!({"id": b})).unwrap_err();
assert!(e.to_string().contains("library"), "{e}");
assert!(s.execute("select.set", &json!({"ids": [remote_button]})).is_err(), "library copies can't be selected");
assert!(s.doc().unwrap().pages.contains(&s.page().unwrap()));
assert!(s.execute("component.pushChanges", &json!({"id": b})).is_err());
@@ -399,6 +397,75 @@ fn add_place_and_update_keep_overrides() {
let _ = std::fs::remove_dir_all(&d);
}
fn go_to_main(s: &mut Session, id: NodeId) -> Value {
s.active = 0;
ex(s, "component.goToMain", json!({"id": id}))
}
#[test]
fn going_to_a_library_main_opens_the_library_file() {
let d = dir("go-to-main");
let lib_path = path_str(&d.join("kit.figcraft"));
let mut lib = Session::new();
let k = kit(&mut lib);
let mut chips = Vec::new();
for (i, color) in ["#ff0000", "#00ff00"].iter().enumerate() {
let f = idv(&ex(&mut lib, "frame.create", json!({"x": 400.0 + i as f64 * 100.0, "y": 0, "width": 60, "height": 20, "name": "Chip"})));
ex(&mut lib, "node.set", json!({"id": f, "props": {"fill": color}}));
chips.push(idv(&ex(&mut lib, "component.create", json!({"ids": [f]}))));
}
ex(&mut lib, "component.combineVariants", json!({"ids": chips}));
let kit_page = lib.doc().unwrap().page_of(k.card).unwrap();
ex(&mut lib, "page.add", json!({"name": "Notes"}));
let lid = publish_to(&mut lib, &lib_path);
let mut s = Session::new();
ex(&mut s, "library.add", json!({"path": lib_path}));
let card = idv(&ex(&mut s, "library.place", json!({"library": lid, "component": "Card", "x": 0, "y": 0})));
let nested_button = node(&s, card).children[0];
let chip = idv(&ex(&mut s, "library.place", json!({"library": lid, "component": "Chip", "x": 0, "y": 200})));
ex(&mut s, "instance.setProperties", json!({"ids": [chip], "properties": {"Property 1": "Variant2"}}));
s.ds_mut().unwrap().view.vw = 900.0;
s.ds_mut().unwrap().view.vh = 500.0;
let using = s.doc().unwrap().clone();
let r = go_to_main(&mut s, card);
assert_eq!((id_of(&r["id"]), r["index"].as_u64()), (Some(k.card), Some(1)), "{r}");
let ds = s.ds().unwrap();
assert_eq!(ds.path.as_deref(), Some(lib_path.as_str()));
assert_eq!((ds.selection.clone(), ds.page), (vec![k.card], kit_page));
assert_eq!((ds.view.vw, ds.view.vh), (900.0, 500.0), "the canvas it opens on is the one shown, so the fit stands");
let shown = ds.doc.world_bounds(k.card).unwrap().center();
assert_eq!((ds.view.cx, ds.view.cy), (shown.x, shown.y));
for (layer, main) in [(nested_button, k.button), (chip, chips[1])] {
let r = go_to_main(&mut s, layer);
assert_eq!((id_of(&r["id"]), r["index"].as_u64()), (Some(main), Some(1)), "{r}");
assert_eq!(s.ds().unwrap().selection, vec![main]);
}
assert_eq!(s.docs.len(), 2, "the open library file comes to the front");
assert_eq!(s.docs[0].doc.as_ref(), &using, "the file going there is unchanged");
s.active = 1;
ex(&mut s, "edit.delete", json!({"ids": [k.card]}));
let r = go_to_main(&mut s, card);
assert!(r["id"].is_null() && r["index"] == 1, "{r}");
assert!(s.message.take().is_some_and(|m| m.contains("isn't in this file any more")));
ex(&mut s, "file.close", json!({"index": 1, "force": true}));
std::fs::rename(&lib_path, d.join("moved.figcraft")).unwrap();
s.active = 0;
let e = s.execute("component.goToMain", &json!({"id": card})).unwrap_err();
assert!(e.to_string().contains("isn't at"), "{e}");
assert_eq!(s.docs.len(), 1);
let NodeKind::Instance { component: copy, .. } = node(&s, card).kind else { panic!("not an instance") };
s.doc_mut().unwrap().libraries_mut().components.retain(|r| r.root != copy);
let e = s.execute("component.goToMain", &json!({"id": card})).unwrap_err();
assert!(e.to_string().contains("no link to its library"), "an imported copy without its record: {e}");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn library_styles_apply_and_import_with_pages() {
let d = dir("styles");
+72 -21
View File
@@ -186,28 +186,42 @@ pub fn specs() -> Vec<CommandSpec> {
true,
reset_override
),
spec!("component.goToMain", "Go to main component", "{id?}", false, |s, p| {
let cmd = "component.goToMain";
let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select an instance"))?;
let doc = s.doc()?;
let inst = owning_instance(doc, i).ok_or_else(|| bad(cmd, "not an instance"))?;
let Some(NodeKind::Instance { component, .. }) = doc.node(inst).map(|n| n.kind.clone()) else {
return Err(bad(cmd, "not an instance"));
};
if !doc.contains(component) {
return Err(EngineError::Disabled(cmd.into(), "the main component was deleted (use component.restoreMain)".into()));
spec!(
"component.goToMain",
"Go to main component",
"{id?} selects the instance's main component; a library component's main is in its library file, which opens in a tab (or comes to the front) with the main selected. Returns {id} (the main in the file now shown), and `index` (the tab) when it went to a library file",
false,
|s, p| {
let cmd = "component.goToMain";
let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select an instance"))?;
let doc = s.doc()?;
let inst = owning_instance(doc, i).ok_or_else(|| bad(cmd, "not an instance"))?;
let Some(NodeKind::Instance { component, .. }) = doc.node(inst).map(|n| n.kind.clone()) else {
return Err(bad(cmd, "not an instance"));
};
if !doc.contains(component) {
return Err(EngineError::Disabled(cmd.into(), "the main component was deleted (use component.restoreMain)".into()));
}
if let Some((library, key)) = crate::library::library_source(doc, component) {
let name = crate::library::library_name(doc, library).to_string();
let library = library.to_string();
return go_to_library_main(s, &library, &name, key);
}
if doc.is_remote(component) {
return Err(EngineError::Disabled(
cmd.into(),
"the main component is a library copy with no link to its library (it came with an imported file): enable that library to reach it"
.into(),
));
}
super::edit::select_and_page(s, vec![component])?;
let ds = s.ds_mut()?;
if let Some(b) = ds.doc.world_bounds(component) {
ds.view.fit(b, 2.0);
}
Ok(json!({"id": component}))
}
if let Some(lib) = crate::library::library_of(doc, component) {
let name = crate::library::library_name(doc, lib);
return Err(EngineError::Disabled(cmd.into(), format!("the main component is in the library \"{name}\": open that file to edit it")));
}
super::edit::select_and_page(s, vec![component])?;
let ds = s.ds_mut()?;
if let Some(b) = ds.doc.world_bounds(component) {
ds.view.fit(b, 2.0);
}
Ok(json!({"id": component}))
}),
),
spec!("component.pushChanges", "Push changes to main component", "{id?}", true, push_changes),
spec!(
"component.restoreMain",
@@ -283,6 +297,43 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
fn go_to_library_main(s: &mut Session, library: &str, name: &str, key: NodeId) -> Result<Value> {
let cmd = "component.goToMain";
let refuse = |why: String| EngineError::Disabled(cmd.into(), why);
let path = s
.doc()?
.libraries
.link(library)
.map(|l| l.path.clone())
.ok_or_else(|| refuse(format!("the library \"{name}\" was removed from this file")))?;
if crate::library_server::is_server_link(&path) {
return Err(refuse(format!("the main component is in the team library \"{name}\": open its file from the server to edit it")));
}
if !craft_docpath::is_local(&path) {
return Err(refuse(format!("{path}: a library is opened only from this computer")));
}
if !crate::file_access::exists(&path) {
return Err(refuse(format!("the library file of \"{name}\" isn't at {path} any more")));
}
let viewport = s.ds().map(|d| (d.view.vw, d.view.vh))?;
let opened = super::file::open(s, &json!({"path": path}))?;
let ds = s.ds_mut()?;
(ds.view.vw, ds.view.vh) = viewport;
let doc = &ds.doc;
let same_library = doc.published.as_ref().is_some_and(|p| p.manifest.id == library);
let main = doc.node(key).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. }));
if !same_library || !main || doc.is_remote(key) {
s.message = Some(format!("{name}: the main component isn't in this file any more"));
return Ok(json!({"id": null, "index": opened["index"]}));
}
super::edit::select_and_page(s, vec![key])?;
let ds = s.ds_mut()?;
if let Some(b) = ds.doc.world_bounds(key) {
ds.view.fit(b, 2.0);
}
Ok(json!({"id": key, "index": opened["index"]}))
}
/// Place an instance of `component` centred on page point (`x`, `y`), in the frame or auto layout
/// slot under it (`instance.place`).
pub(crate) fn place(s: &mut Session, p: &Value) -> Result<Value> {
+2 -1
View File
@@ -37,7 +37,8 @@ pub(crate) use load::{load_fig, read_capped, recovered, recovered_from, variable
pub use open::MAX_RECENT;
#[cfg(test)]
use open::append_pages;
use open::{import, open, open_recent, revert};
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};
use save::{color_profile, mark_saved, save, save_as};
+1 -1
View File
@@ -54,7 +54,7 @@ pub(super) fn open_recent(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"files": files}))
}
pub(super) fn open(s: &mut Session, p: &Value) -> Result<Value> {
pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
let variables = variables_param("file.open", p)?;
if string(p, "path").is_none()
&& let Some(d) = string(p, "data")
+25 -9
View File
@@ -195,21 +195,37 @@ fn pointer_event(app: &FigApp, kind: PointerKind, pos: Pos2, m: &egui::Modifiers
/// labels, handles, comments, versions/branch/dev/live UI and the cursor.
pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
let rect = ui.max_rect();
app.canvas.rect = rect;
let old_rect = std::mem::replace(&mut app.canvas.rect, rect);
let ctx = ui.ctx().clone();
let ppp = ctx.pixels_per_point();
let resp = ui.interact(rect, ui.scope_id().with("canvas"), Sense::click_and_drag());
let key_box = app.ui.key_box;
crate::a11y::canvas(&mut app.ui.nav, &resp, || key_box.map_or_else(|| describe(&app.session), describe_box));
if let Ok(ds) = app.session.ds_mut()
&& ((ds.view.vw - f64::from(rect.width())).abs() > 0.5 || (ds.view.vh - f64::from(rect.height())).abs() > 0.5)
{
// Keep the centre; the first sizing after opening re-fits.
if let Ok(ds) = app.session.ds_mut() {
let resized = (ds.view.vw - f64::from(rect.width())).abs() > 0.5 || (ds.view.vh - f64::from(rect.height())).abs() > 0.5;
// The first sizing after opening re-fits.
let first = ds.view.vw == 1200.0 && ds.view.vh == 800.0;
ds.view.vw = f64::from(rect.width());
ds.view.vh = f64::from(rect.height());
if first {
ds.zoom_to_fit();
// While a button is held that went down on the canvas (a click that selects shows the
// right panel, minimized, and a click that deselects hides it; the left panel and rail
// can appear too) the page point under a still pointer stays where it is: the gesture's
// own pointer events would otherwise map to another page point and the move handling
// would take that for a drag. `to_page` is `origin + (p - rect.min) / z` with
// `origin = c - vw / 2 / z`, so the centre moves by half the size change plus the
// rect's own move, over the zoom.
let held = resp.is_pointer_button_down_on() && !first && old_rect.is_finite();
let z = ds.view.zoom.max(1e-9);
let (dw, dh) = if resized { (f64::from(rect.width()) - ds.view.vw, f64::from(rect.height()) - ds.view.vh) } else { (0.0, 0.0) };
if resized {
// Otherwise keep the centre.
ds.view.vw = f64::from(rect.width());
ds.view.vh = f64::from(rect.height());
if first {
ds.zoom_to_fit();
}
}
if held {
ds.view.cx += (dw / 2.0 + f64::from(rect.min.x - old_rect.min.x)) / z;
ds.view.cy += (dh / 2.0 + f64::from(rect.min.y - old_rect.min.y)) / z;
}
}
let own_cursor = input(app, ui, &resp);
+8 -5
View File
@@ -129,9 +129,6 @@ fn clipboard_png(app: &mut FigApp) -> Option<(Vec<u8>, f64, f64)> {
Some(readable) => readable,
None => with_pixel_size(encoded_clipboard_image(app)?)?,
};
if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_PASTED_IMAGE_PIXELS {
return None;
}
let density = figcraft_format::pixel_density(&png);
let (w, h) = figcraft_format::placed_size(w, h, density, figcraft_engine::cmd::create::PLACED_IMAGE_MAX_SIDE);
Some((png, w, h))
@@ -140,8 +137,14 @@ fn clipboard_png(app: &mut FigApp) -> Option<(Vec<u8>, f64, f64)> {
const MAX_PASTED_IMAGE_PIXELS: u64 = 100_000_000;
fn with_pixel_size(png: Vec<u8>) -> Option<(Vec<u8>, (u32, u32))> {
let size = image::ImageReader::new(std::io::Cursor::new(&png)).with_guessed_format().ok()?.into_dimensions().ok()?;
Some((png, size))
use image::ImageDecoder as _;
let decoder = image::ImageReader::new(std::io::Cursor::new(&png)).with_guessed_format().ok()?.into_decoder().ok()?;
let (w, h) = decoder.dimensions();
if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_PASTED_IMAGE_PIXELS {
return None;
}
image::DynamicImage::from_decoder(decoder).ok()?;
Some((png, (w, h)))
}
fn encoded_clipboard_image(app: &mut FigApp) -> Option<Vec<u8>> {
+127
View File
@@ -346,3 +346,130 @@ fn a_narrow_window_shows_one_side_panel_at_a_time() {
let wide = canvas_width(&mut app, vec2(1440.0, 900.0));
assert!(wide > 600.0 && wide < 1100.0, "a wide window shows both panels beside the canvas ({wide})");
}
/// Five frames side by side at 46% zoom with the interface minimized and nothing selected.
fn minimized_row() -> (Ui, Vec<figcraft_doc::NodeId>) {
let mut s = Session::new();
let mut ids = Vec::new();
for k in 0..5 {
let r = s.execute("frame.create", &json!({"x": k * 470, "y": 0, "width": 400, "height": 600})).unwrap();
ids.push(figcraft_engine::params::id_of(&r["id"]).unwrap());
}
s.execute("select.set", &json!({"ids": []})).unwrap();
let mut u = Ui::new(s);
u.app.run("view.minimize", json!({"on": true}));
u.app.run("view.set", json!({"zoom": 0.46, "cx": 1500, "cy": 300}));
for _ in 0..4 {
u.frame(vec![]);
}
(u, ids)
}
fn frame_x(u: &Ui, id: figcraft_doc::NodeId) -> f64 {
u.app.session.doc().unwrap().world_bounds(id).unwrap().x0
}
/// A click on a frame while minimized selects it and shows the right panel (the canvas gets
/// narrower): the frame stays where it was, and no step is added to the history.
#[test]
fn a_click_that_shows_the_right_panel_does_not_move_the_frame() {
let (mut u, ids) = minimized_row();
assert!(u.app.ui.right_panel_rect.is_none(), "nothing selected: no right panel");
let wide = u.app.canvas.rect.width();
let steps = u.app.session.ds().unwrap().history.undo.len();
let p = u.app.to_screen(1880.0 + 200.0, 300.0);
u.frame(vec![Event::PointerMoved(p)]);
u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }]);
u.frame(vec![]);
u.frame(vec![]);
u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }]);
for _ in 0..4 {
u.frame(vec![]);
}
assert_eq!(u.app.session.selection(), vec![ids[4]], "the click selects the frame");
assert!(u.app.ui.right_panel_rect.is_some(), "the right panel shows");
assert!(u.app.canvas.rect.width() < wide - 100.0, "the canvas is narrower");
assert_eq!(frame_x(&u, ids[4]), 1880.0, "a click did not move the frame");
assert_eq!(u.app.session.ds().unwrap().history.undo.len(), steps, "no history step");
}
/// A real drag still moves the frame, by the pointer's screen movement over the zoom, in one step.
#[test]
fn a_drag_after_the_right_panel_shows_moves_the_frame_by_the_pointer() {
let (mut u, ids) = minimized_row();
let steps = u.app.session.ds().unwrap().history.undo.len();
let p = u.app.to_screen(1880.0 + 200.0, 300.0);
u.frame(vec![Event::PointerMoved(p)]);
u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }]);
u.frame(vec![]);
u.frame(vec![Event::PointerMoved(p + vec2(50.0, 0.0))]);
u.frame(vec![Event::PointerMoved(p + vec2(100.0, 0.0))]);
u.frame(vec![Event::PointerButton {
pos: p + vec2(100.0, 0.0),
button: egui::PointerButton::Primary,
pressed: false,
modifiers: Default::default(),
}]);
for _ in 0..3 {
u.frame(vec![]);
}
assert!(u.app.ui.right_panel_rect.is_some(), "the right panel shows");
let moved = frame_x(&u, ids[4]) - 1880.0;
assert!((moved - 100.0 / 0.46).abs() < 6.0, "moved by 100 points over the zoom, got {moved}");
assert_eq!(u.app.session.ds().unwrap().history.undo.len(), steps + 1, "one step");
}
/// Clicking empty canvas deselects and hides the right panel again; the marquee that starts there
/// has no offset: nothing moves and nothing is selected.
#[test]
fn a_click_that_hides_the_right_panel_does_not_move_the_frame() {
let (mut u, ids) = minimized_row();
u.app.run("select.set", json!({"ids": [ids[4]]}));
for _ in 0..3 {
u.frame(vec![]);
}
assert!(u.app.ui.right_panel_rect.is_some());
let p = u.app.to_screen(1880.0 + 200.0, 1000.0);
let steps = u.app.session.ds().unwrap().history.undo.len();
u.frame(vec![Event::PointerMoved(p)]);
u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }]);
u.frame(vec![]);
u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }]);
for _ in 0..3 {
u.frame(vec![]);
}
assert!(u.app.ui.right_panel_rect.is_none(), "deselected: no right panel");
assert_eq!(frame_x(&u, ids[4]), 1880.0);
assert_eq!(u.app.session.ds().unwrap().history.undo.len(), steps);
}
/// The left sidebar and rail appearing while a press is held (Minimize toggled off mid-press)
/// moves the canvas's left edge: a still pointer keeps its page point, so the release moves nothing.
#[test]
fn a_held_press_keeps_its_page_point_when_the_canvas_moves() {
let (mut u, ids) = minimized_row();
u.app.run("select.set", json!({"ids": [ids[3]]}));
for _ in 0..3 {
u.frame(vec![]);
}
let steps = u.app.session.ds().unwrap().history.undo.len();
let left = u.app.canvas.rect.min.x;
let p = u.app.to_screen(1880.0 + 200.0, 300.0);
u.frame(vec![Event::PointerMoved(p)]);
u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }]);
u.frame(vec![]);
let before = u.app.to_page(p);
u.app.run("view.minimize", json!({"on": false}));
u.frame(vec![]);
u.frame(vec![]);
assert!(u.app.canvas.rect.min.x > left + 100.0, "the left sidebar and rail moved the canvas's edge");
let after = u.app.to_page(p);
assert!((after.x - before.x).abs() < 0.5 && (after.y - before.y).abs() < 0.5, "the pointer's page point moved: {before:?} to {after:?}");
u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }]);
for _ in 0..3 {
u.frame(vec![]);
}
assert_eq!(frame_x(&u, ids[4]), 1880.0, "the frame did not move");
assert_eq!(frame_x(&u, ids[3]), 1410.0);
assert_eq!(u.app.session.ds().unwrap().history.undo.len(), steps, "no history step");
}
+15
View File
@@ -549,3 +549,18 @@ fn a_clipboard_png_past_the_byte_limit_falls_back_to_the_decoded_image() {
let pasted = doc.nodes().find(|n| n.name == "Pasted image").expect("pasted from the decoded image");
assert_eq!((pasted.width, pasted.height), (2.0, 2.0));
}
#[test]
fn a_clipboard_png_with_a_cut_off_body_falls_back_to_the_decoded_image() {
let mut w = Win::new();
let mut png = std::io::Cursor::new(Vec::new());
image::RgbaImage::new(4, 4).write_to(&mut png, image::ImageFormat::Png).unwrap();
let png = png.into_inner();
let cut_off = png[..png.len() - 20].to_vec();
assert_eq!(image::ImageReader::new(std::io::Cursor::new(&cut_off)).with_guessed_format().unwrap().into_dimensions().unwrap(), (4, 4));
*w.clip.borrow_mut() = Clip { png: Some(cut_off), image: Some((2, 2, vec![255; 16])), ..Clip::default() };
w.cmd_v();
let doc = w.app.session.doc().unwrap();
let pasted = doc.nodes().find(|n| n.name == "Pasted image").expect("pasted from the decoded image");
assert_eq!((pasted.width, pasted.height), (2.0, 2.0));
}
+3 -1
View File
@@ -1,6 +1,6 @@
# Components, variants, styles and variables parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (a layer's effect binding takes only a field its effect has; a bound glass light angle past ±180 turns round; 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-06 · **Change:** minor (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
@@ -50,6 +50,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
|---|---|---|
| Publish a file's components, sets, styles and variables (per-asset versions) | done | as local library files |
| Enable a library in a file; read-only linked copies | done | |
| Go to main component from an instance of a library component | done | opens the library file in a tab, or brings it to the front, on the page holding the main, selected and zoomed to; a variant goes to that variant. Team libraries on a server are refused with a note; no Return to instance yet |
| Library updates available; overrides kept | done | a library file saved again within two seconds of being read, at the same length, is read again (its time stamp can't prove it unchanged; #652) |
| Team libraries: publish to a `figcraft-server` under the file, enable by a share link (a view link reads it), background update checks | done | [server.md](server.md#team-libraries); no team-wide library list or accounts ([gaps](gaps.md) #10) |
| Library analytics | partial | instances per component and the files using them, and the instances each file detached (per component and per file), as apps report them; insertions and detachments week by week for a year (Show usage: the past 30 days, 90 days or year), for components; the layers and files using each style and variable, without insertions, detachments or modes; no chart |
@@ -80,6 +81,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
|---|---|---|
| 2026-11-06 | patch | M3: a glass's light angle bound to a number past ±180 turns round to the same direction (270 is -90), as the angle drawn from it does, instead of stopping at 180. Tests: `effects_tests::a_bound_light_angle_turns_round_instead_of_stopping`. Grade unchanged. |
| 2026-11-06 | patch | M3: a layer's effect binding (`variable.bind`, `effects/<i>/<field>`) takes only a field its effect has, as an effect style's does: `effects/0/refraction` on a drop shadow, or a binding on an effect the layer doesn't have, is refused instead of being kept and doing nothing (a binding an older file has there still does nothing, and unbinds). Tests: `binding_lists_tests::a_layer_binds_only_fields_its_effects_have`. Grade unchanged. |
| 2026-11-06 | minor | M3: Go to main component (context menu, Design panel, Ctrl+Alt+Cmd+K) on an instance of a library component opens the library file, as the help center describes for mains kept in another file ("Edit main components"); it used to refuse. The file opens in a tab, or an open tab comes to the front, on the main's page with the main selected and zoomed to: a nested instance goes to its own main, a variant to that variant. A main deleted from the library file since it was published leaves the file open with a note; a library file that moved, a team library on a server and a path off this computer are refused with a message. Test: `library::tests::going_to_a_library_main_opens_the_library_file`. Numbers unchanged |
| 2026-11-02 | minor | M3, #1885: glass effects take variables, as the reference tool's do: its light angle, intensity, refraction, depth, dispersion, frost and splay bind to number variables on a layer (`variable.bind`, `effects/<i>/<lightAngle\|lightIntensity\|refraction\|depth\|dispersion\|radius\|splay>`, `radius` being the frost) and in an effect style (`style.bind`), and the Design panel's glass fields take a variable like the shadow and blur fields. A variable reads as the field shows it: intensity, refraction, dispersion and splay in percent. Tests: `binding_lists_tests::glass_fields_take_number_variables`, `effects_tests::glass_fields_take_bound_numbers_as_the_panel_shows_them`. Grade unchanged. |
| 2026-11-01 | patch | M3: Delete on a layer inside an instance hides it (an override) instead of being refused ("You can't delete a layer or object from an instance. If you try, Figma will only toggle the layer's visibility instead of removing it", Guide to auto layout); `edit.delete` reports those ids under `hidden`, the structure and the main component stay as they were, one undo step; deleting the instance itself still removes it. Found by a documentation audit of the auto layout page. Test: `components_behaviour_tests::deleting_a_layer_inside_an_instance_hides_it_instead` (the refusal row for it is gone from `what_instances_cannot_override_is_refused`). Numbers unchanged |
| 2026-10-31 | minor | M3, gap 10: library analytics cover styles and variables. A file reports how many of its layers use each of a library's styles and variables, and Show usage (`library.usage`) lists them beside the components, by a Type choice, with their layers and files. Layers inside instances count for their component. Insertions, detachments and modes aren't counted for them yet. Numbers unchanged. Tests: `library_assets_tests`. |