Merge main into #2244

This commit is contained in:
bflatastic committed 2026-11-12 11:17:56 +07:00
commit ad9933b8ed
60 files changed
+1423 -79

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (mind maps, first step: a first node with three children on curved branches, laid out again after every edit (`board.mindMap`, `board.mindMapAdd`, #2226); the handoff mode reads Developer Mode in the interface, its ids and saved fields unchanged; the left rail's and tab strip's icons are larger, 20 pt (#553); Suggest auto layout adds nested auto layout frames by rows and columns and keeps the placement of the design, as the help center describes (#1823); 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); the desktop UI takes a system face for Ethiopic, Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada and Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian and Georgian as it did for Hebrew and Arabic, so a String variable's Amharic value draws in the panels instead of boxes (#2014); a layer's effect binding takes only a field its effect has; ; the boolean operation menu leads each entry with an original icon (#2003); glass takes one sample instead of its dispersion window where the colours split under 1/256 px; glass bends towards its core, and rounded rectangles work it out in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps; glass effects take variables and keep their frost within 0–100; web host bridge: `?host=parent` or `data-host="page"` hands a page the app's files, documented in the hosting guide; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); 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; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library Line truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (a text style binds a number variable to its weight (#2174); mind maps, first step: a first node with three children on curved branches, laid out again after every edit (`board.mindMap`, `board.mindMapAdd`, #2226); the handoff mode reads Developer Mode in the interface, its ids and saved fields unchanged; the left rail's and tab strip's icons are larger, 20 pt (#553); Suggest auto layout adds nested auto layout frames by rows and columns and keeps the placement of the design, as the help center describes (#1823); 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); the desktop UI takes a system face for Ethiopic, Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada and Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian and Georgian as it did for Hebrew and Arabic, so a String variable's Amharic value draws in the panels instead of boxes (#2014); a layer's effect binding takes only a field its effect has; ; the boolean operation menu leads each entry with an original icon (#2003); glass takes one sample instead of its dispersion window where the colours split under 1/256 px; glass bends towards its core, and rounded rectangles work it out in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps; glass effects take variables and keep their frost within 0–100; web host bridge: `?host=parent` or `data-host="page"` hands a page the app's files, documented in the hosting guide; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); 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; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their linksLine truncated
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
+9
View File
@@ -1453,8 +1453,10 @@ pub struct PrintSettings {
/// is printed this far out, to be trimmed off.
pub bleed: f64,
/// Crop marks outside the bleed, at the trim's corners.
#[serde(alias = "crop_marks")]
pub crop_marks: bool,
/// Bleed marks outside the bleed, at the bleed's corners.
#[serde(alias = "bleed_marks")]
pub bleed_marks: bool,
/// CMYK colour (converted from RGB) instead of RGB.
pub cmyk: bool,
@@ -1523,25 +1525,32 @@ pub struct ExportSetting {
pub print: PrintSettings,
/// Only the layer itself (on), or every layer that shows in its area (off).
#[serde(skip_serializing_if = "is_true")]
#[serde(alias = "contents_only")]
pub contents_only: bool,
/// The layer's own box, cutting off what it paints outside it (shadows, outside strokes).
#[serde(skip_serializing_if = "is_false")]
#[serde(alias = "include_bounding_box")]
pub include_bounding_box: bool,
/// SVG: text as outlines (on) or as `<text>` elements (off).
#[serde(skip_serializing_if = "is_true")]
#[serde(alias = "svg_outline_text")]
pub svg_outline_text: bool,
/// SVG: layer names as `id` attributes.
#[serde(skip_serializing_if = "is_false")]
#[serde(alias = "svg_id_attribute")]
pub svg_id_attribute: bool,
/// SVG: strokes as `stroke` attributes where that's exact (on), else as outlines.
#[serde(skip_serializing_if = "is_true")]
#[serde(alias = "svg_simplify_stroke")]
pub svg_simplify_stroke: bool,
/// Android Vector Drawable: what to do with what a drawable can't hold exactly.
#[serde(skip_serializing_if = "VdCompat::is_default")]
#[serde(alias = "vd_compat")]
pub vd_compat: VdCompat,
/// PNG, JPG and WebP: the color profile the file is written in. SVG, PDF and XML are always
/// sRGB, whatever this says.
#[serde(skip_serializing_if = "ExportColorProfile::is_default")]
#[serde(alias = "color_profile")]
pub color_profile: ExportColorProfile,
/// JPG: the encoder's quality, 1 (smallest file) to 100 (see [`ExportSetting::quality`]).
#[serde(skip_serializing_if = "is_default_quality")]
+40
View File
@@ -902,6 +902,46 @@ fn a_chamfered_rectangles_rounded_fill_is_cut_at_every_point() {
}
}
/// The reporter's network (#1951): a corner cut off by a segment drawn before the corner point was
/// deleted, so the region's loop is the open chain of the other four segments and the cutting
/// segment, joining the chain's two ends, is in no loop. Rounded, the fill and the region trace
/// the stroke's outline, cut by an arc at the chain's ends as at every other point.
#[test]
fn a_rounded_loop_left_open_where_a_segment_joins_its_ends_follows_that_segment() {
open_loop_follows_the_segment_joining_its_ends([(0, 3), (1, 2), (2, 0), (4, 1), (3, 4)], vec![3, 1, 2, 0]);
}
/// The same network with the joining segment listed first, and with the loop walked the other way.
#[test]
fn an_open_loop_follows_the_segment_joining_its_ends_listed_first_or_walked_backwards() {
open_loop_follows_the_segment_joining_its_ends([(3, 4), (0, 3), (1, 2), (2, 0), (4, 1)], vec![4, 2, 3, 1]);
open_loop_follows_the_segment_joining_its_ends([(0, 3), (1, 2), (2, 0), (4, 1), (3, 4)], vec![0, 2, 1, 3]);
}
fn open_loop_follows_the_segment_joining_its_ends(segments: [(usize, usize); 5], open_loop: Vec<usize>) {
use figcraft_geom::BezPath;
use figcraft_geom::kurbo::{ParamCurveArclen, ParamCurveNearest};
let corners = [Point::new(0.0, 0.0), Point::new(525.0, 525.0), Point::new(0.0, 525.0), Point::new(333.0, 0.0), Point::new(525.0, 183.0)];
let mut net = VectorNetwork::from_points(&corners, false);
net.segments = segments.iter().map(|&(a, b)| Segment::line(a, b)).collect();
net.regions.push(Region { loops: vec![open_loop], winding: WindingRule::NonZero, fills: None, paint_space: None });
for v in &mut net.vertices {
v.radius = 125.0;
}
let keeps_off = |path: &BezPath, p: Point| path.segments().map(|s| s.nearest(p, 1e-9).distance_sq.sqrt()).fold(f64::INFINITY, f64::min);
let length = |path: &BezPath| path.segments().map(|s| s.arclen(1e-9)).sum::<f64>();
let stroke = net.stroke_path();
let fill = net.fill_path();
let region = net.rounded().unwrap().region_path(0).unwrap();
for (i, p) in corners.iter().enumerate() {
assert!(keeps_off(&stroke, *p) > 2.0, "the stroke is cut at point {i}");
assert!(keeps_off(&fill, *p) > 2.0, "the fill runs into point {i} ({:.2} from it)", keeps_off(&fill, *p));
assert!(keeps_off(&region, *p) > 2.0, "the region runs into point {i} ({:.2} from it)", keeps_off(&region, *p));
}
assert!((length(&fill) - length(&stroke)).abs() < 1e-6, "fill {} and stroke {} trace the same outline", length(&fill), length(&stroke));
assert!((length(&region) - length(&stroke)).abs() < 1e-6, "region {} and stroke {} trace the same outline", length(&region), length(&stroke));
}
#[test]
fn stored_variable_ids_resolve_only_their_exact_collection() {
let mut vars = crate::proto_expr_tests::vars();
+69 -8
View File
@@ -184,6 +184,13 @@ fn redirect_last_segment(net: &mut VectorNetwork, loop_segs: &[usize], from: usi
}
}
/// Which segments are in a loop, and which segments join each pair of points: the lookups
/// [`VectorNetwork::closing_segment`] reads, built once per pass by [`VectorNetwork::loop_index`].
pub struct LoopIndex {
in_loop: Vec<bool>,
by_ends: std::collections::BTreeMap<(usize, usize), Vec<usize>>,
}
impl VectorNetwork {
/// A network from a polyline / polygon.
pub fn from_points(points: &[Point], closed: bool) -> Self {
@@ -471,16 +478,16 @@ impl VectorNetwork {
// The curves go between the two segments wherever a loop walks through the point, in the
// order the loop meets them. Each loop is rebuilt in one pass, so a polygon with many
// rounded corners isn't quadratic.
let index = self.loop_index();
for lp in out.regions.iter_mut().flat_map(|r| r.loops.iter_mut()) {
let Some(&first_idx) = lp.first() else { continue };
let start_v = match (self.segments.get(first_idx), lp.get(1).and_then(|&i| self.segments.get(i))) {
(Some(first), Some(second)) if first.a == second.a || first.a == second.b => first.b,
(Some(first), _) => first.a,
_ => 0,
};
let mut rebuilt = Vec::with_capacity(lp.len() + by_vertex.len().min(lp.len()));
if lp.is_empty() {
continue;
}
let start_v = self.loop_start(lp).unwrap_or(0);
let mut rebuilt = Vec::with_capacity(lp.len() + by_vertex.len().min(lp.len()) + 1);
let mut cur = start_v;
for &x in lp.iter() {
let closing = self.closing_segment(lp, &index);
for &x in lp.iter().chain(closing.iter()) {
rebuilt.push(x);
let Some(s) = self.segments.get(x) else { continue };
cur = if s.a == cur { s.b } else { s.a };
@@ -497,6 +504,60 @@ impl VectorNetwork {
Some(out)
}
/// The point loop `lp` is walked from: the end of its first segment that its second does not
/// share.
fn loop_start(&self, lp: &[usize]) -> Option<usize> {
let first = self.segments.get(*lp.first()?)?;
match lp.get(1).and_then(|&i| self.segments.get(i)) {
Some(second) if first.a == second.a || first.a == second.b => Some(first.b),
_ => Some(first.a),
}
}
/// The lookups [`Self::closing_segment`] needs, built once for a pass over every loop.
pub fn loop_index(&self) -> LoopIndex {
let mut in_loop = vec![false; self.segments.len()];
for &x in self.regions.iter().flat_map(|r| r.loops.iter().flatten()) {
if let Some(f) = in_loop.get_mut(x) {
*f = true;
}
}
let mut by_ends: std::collections::BTreeMap<(usize, usize), Vec<usize>> = std::collections::BTreeMap::new();
for (i, s) in self.segments.iter().enumerate() {
by_ends.entry((s.a.min(s.b), s.a.max(s.b))).or_default().push(i);
}
LoopIndex { in_loop, by_ends }
}
/// The segment that joins the two ends of an open loop `lp` when it is in no loop of its own
/// (and the only such segment): a corner cut off by a segment drawn before the corner point was
/// deleted leaves the loop open there, and the stroke follows that segment. Only a loop that
/// walks in order as one continuous chain has one. Rounding walks it as the loop's last
/// segment, so the points at the loop's ends are cut by arcs as the stroke's are, and deleting
/// points or segments puts it into the loop (#1951). `index` is this network's [`Self::loop_index`],
/// shared by every loop of one pass.
pub fn closing_segment(&self, lp: &[usize], index: &LoopIndex) -> Option<usize> {
let start_v = self.loop_start(lp)?;
let mut cur = start_v;
for &x in lp {
let s = self.segments.get(x)?;
cur = if s.a == cur {
s.b
} else if s.b == cur {
s.a
} else {
return None;
};
}
if cur == start_v {
return None;
}
let mut joining =
index.by_ends.get(&(cur.min(start_v), cur.max(start_v)))?.iter().copied().filter(|&i| !index.in_loop.get(i).copied().unwrap_or(false));
let first = joining.next()?;
joining.next().is_none().then_some(first)
}
/// How point `v` (its `incident` segments) is rounded, if it is: not where the two segments
/// run on smoothly or fold back. With `smoothing`, a point between two straight segments that
/// are both `on_loop` gets a smoothed corner.
+14 -3
View File
@@ -1,7 +1,8 @@
//! Variables bound inside shared styles. A style's `bound` map names fields of its own value in
//! the keys a layer's bindings use (`fills/<i>/color`, `fills/<i>/stops/<j>/color`,
//! `effects/<i>/<field>`, `layoutGrids/<i>/<field>`, a text style's `fontSize`, `lineHeight`,
//! `letterSpacing`, `fontFamily`, `fontStyle`). Each layer using the style sees those fields
//! `letterSpacing`, `fontWeight`, `fontFamily`, `fontStyle`; a number weight and a style name set the
//! same property, the weight wins). Each layer using the style sees those fields
//! resolved in its own modes; the style itself stores them resolved in the default modes.
use std::borrow::Cow;
@@ -32,7 +33,7 @@ pub fn field_type(kind: &StyleKind, key: &str) -> Option<VariableType> {
(StyleKind::Effect { effects }, Some((BoundList::Effects, i, field))) => effect_field_type(&effects.get(i)?.kind, field),
(StyleKind::Grid { grids }, Some((BoundList::Grids, i, "size" | "count" | "gutter" | "offset"))) => grids.get(i).map(|_| Number),
(StyleKind::Text { .. }, None) => match key {
"fontSize" | "lineHeight" | "letterSpacing" => Some(Number),
"fontSize" | "lineHeight" | "letterSpacing" | "fontWeight" => Some(Number),
"fontFamily" | "fontStyle" => Some(String),
_ => None,
},
@@ -70,6 +71,7 @@ pub fn field_scopes(kind: &StyleKind, key: &str) -> Option<&'static [&'static st
"letterSpacing" => &["LETTER_SPACING"],
"fontFamily" => &["FONT_FAMILY"],
"fontStyle" => &["FONT_STYLE"],
"fontWeight" => &["FONT_WEIGHT"],
_ => return None,
},
_ => return None,
@@ -111,6 +113,8 @@ pub fn from_layer(n: &Node, field: &str, kind: &StyleKind) -> BTreeMap<String, V
/// `st`'s value with its bound fields resolved in `modes`.
fn resolved(vars: &Variables, st: &Style, modes: &Modes) -> StyleKind {
let mut kind = st.kind.clone();
// `bound` is a BTreeMap, so `fontStyle` applies before `fontWeight` and a weight wins when a file carries both
// (pinned by `font_weight_wins_over_font_style_when_a_file_carries_both`).
for (key, var) in &st.bound {
let Some(val) = vars.resolve(var, modes) else { continue };
match (&mut kind, list_binding(key), &val) {
@@ -134,7 +138,14 @@ fn set_text_field(style: &mut TextStyle, key: &str, val: &VariableValue) {
("lineHeight", VariableValue::Number(x)) if x.is_finite() => style.line_height = LineHeight::Pixels(x.clamp(0.0, 100_000.0)),
("letterSpacing", VariableValue::Number(x)) if x.is_finite() => style.letter_spacing = LetterSpacing::Pixels(x.clamp(-10_000.0, 10_000.0)),
("fontFamily", VariableValue::String(f)) if !f.trim().is_empty() => style.font_family = f.clone(),
("fontStyle", VariableValue::String(f)) if !f.trim().is_empty() => style.font_style = f.clone(),
("fontStyle", VariableValue::String(f)) if !f.trim().is_empty() => style.set_font_style(f.clone()),
("fontWeight", VariableValue::Number(x)) if x.is_finite() => {
// The face nearest the number (a static family) and the `wght` axis (a variable font).
let w = x.clamp(1.0, 1000.0);
let italic = style.italic();
style.set_font_style(crate::cmd::props::styled_name(crate::cmd::props::weight_name(w as u16), italic));
style.variations.insert("wght".to_string(), w);
}
_ => {}
}
}
+8 -1
View File
@@ -1,6 +1,6 @@
//! What a file's number variables measure, so exported tokens carry the right unit: a radius,
//! font size, spacing or other length is in pixels, opacity and font weight are bare numbers.
//! A number's role comes from the layer fields bound to it, else its scopes, and an alias target
//! A number's role comes from the layer fields bound to it (and a text style's weight), else its scopes, and an alias target
//! with no role of its own takes the role of the variables that read it (the target of an opacity
//! alias stays unitless). A number nothing tells about is a length: design tokens mostly are.
@@ -60,6 +60,13 @@ impl Roles {
}
}
}
for st in &doc.styles {
for (field, id) in &st.bound {
if field == "fontWeight" {
roles.entry(id.clone()).or_insert(NumberRole::Unitless);
}
}
}
for v in vars.iter().filter(|v| v.kind == VariableType::Number) {
if let Some(r) = v.scopes.as_ref().and_then(|s| s.iter().find_map(|s| role_of_scope(s))) {
roles.entry(v.id.clone()).or_insert(r);
+10 -1
View File
@@ -58,6 +58,7 @@ pub enum ToolId {
Text,
TextOnPath,
Comment,
Annotation,
Eyedropper,
ShapeBuilder,
// Board tools.
@@ -71,7 +72,7 @@ pub enum ToolId {
}
impl ToolId {
pub const ALL: [ToolId; 26] = [
pub const ALL: [ToolId; 27] = [
ToolId::Move,
ToolId::Scale,
ToolId::Hand,
@@ -89,6 +90,7 @@ impl ToolId {
ToolId::Text,
ToolId::TextOnPath,
ToolId::Comment,
ToolId::Annotation,
ToolId::Eyedropper,
ToolId::ShapeBuilder,
ToolId::Sticky,
@@ -118,6 +120,7 @@ impl ToolId {
ToolId::Text => "text",
ToolId::TextOnPath => "textOnPath",
ToolId::Comment => "comment",
ToolId::Annotation => "annotation",
ToolId::Eyedropper => "eyedropper",
ToolId::ShapeBuilder => "shapeBuilder",
ToolId::Sticky => "sticky",
@@ -129,6 +132,11 @@ impl ToolId {
ToolId::Stamp => "stamp",
}
}
/// Whether Developer Mode keeps this tool: it only inspects, so the tools that edit leave it.
/// The one list behind the engine's pointer routing and command allowlist and the toolbar.
pub fn works_in_dev_mode(self) -> bool {
matches!(self, ToolId::Move | ToolId::Hand | ToolId::Comment | ToolId::Annotation)
}
pub fn from_id(s: &str) -> Option<ToolId> {
ToolId::ALL.into_iter().find(|t| t.id().eq_ignore_ascii_case(s))
}
@@ -151,6 +159,7 @@ impl ToolId {
ToolId::Text => "Text",
ToolId::TextOnPath => "Text on a path",
ToolId::Comment => "Comment",
ToolId::Annotation => "Annotation",
ToolId::Eyedropper => "Pick color",
ToolId::ShapeBuilder => "Shape builder",
ToolId::Sticky => "Sticky note",
+15 -1
View File
@@ -110,7 +110,7 @@ impl Session {
/// The tool pointer events go to: in Dev Mode every tool but the hand and comments selects.
fn acting_tool(&self) -> ToolId {
match self.tool.tool {
t if self.tool.dev_mode && !matches!(t, ToolId::Hand | ToolId::Comment) => ToolId::Move,
t if self.tool.dev_mode && !t.works_in_dev_mode() => ToolId::Move,
t => t,
}
}
@@ -176,6 +176,7 @@ impl Session {
self.request_ui("comment.new", json!({"x": ev.x, "y": ev.y}));
Ok(())
}
ToolId::Annotation => self.annotation_down(ev),
ToolId::Eyedropper => self.eyedrop(ev),
t if t.on_boards() => self.board_down(ev),
t if t.draws_shape() => self.draw_down(ev),
@@ -206,6 +207,19 @@ impl Session {
Ok(true)
}
/// The Annotation tool's press: the layer under the pointer is selected and the UI is asked
/// for its note (the engine writes nothing; the note is typed in the Inspect tab). A press on
/// empty canvas does nothing.
fn annotation_down(&mut self, ev: &PointerEvent) -> Result<()> {
let ds = self.ds()?;
let (doc, page) = (ds.doc.clone(), ds.page);
// The same reach as the Move tool's pick (`ev.tol() * 2.0`); never the page itself.
let Some(id) = self.hit(&doc, page, ev.p(), ev.tol() * 2.0).filter(|i| doc.node(*i).is_some_and(|n| !n.is_page())) else { return Ok(()) };
self.set_selection(vec![id]);
self.request_ui("annotation.new", json!({"id": id}));
Ok(())
}
pub(super) fn hover(&mut self, ev: &PointerEvent) -> Result<()> {
let tool = self.acting_tool();
let ds = self.ds()?;
+209
View File
@@ -312,6 +312,7 @@ fn remove_vertex_index(net: &mut VectorNetwork, vi: usize) {
/// Remove segments (fixing region loops); points left without any segment go too.
pub fn remove_segments(net: &mut VectorNetwork, segs: &[usize]) {
let before = net.segments.len();
let mut touched: Vec<usize> = segs.iter().filter_map(|s| net.segments.get(*s)).flat_map(|s| [s.a, s.b]).collect();
remove_segments_only(net, segs);
touched.sort_unstable();
@@ -322,6 +323,9 @@ pub fn remove_segments(net: &mut VectorNetwork, segs: &[usize]) {
}
}
reopen_loops(net);
if net.segments.len() != before {
close_bridged_loops(net);
}
}
fn remove_segments_only(net: &mut VectorNetwork, segs: &[usize]) {
@@ -349,6 +353,7 @@ fn remove_segments_only(net: &mut VectorNetwork, segs: &[usize]) {
/// Delete vertices, joining their neighbours where a vertex had exactly two segments (`heal`).
pub fn delete_vertices(net: &mut VectorNetwork, idx: &[usize], heal: bool) {
let before = net.vertices.len();
let mut idx: Vec<usize> = idx.to_vec();
idx.sort_unstable();
idx.dedup();
@@ -408,6 +413,9 @@ pub fn delete_vertices(net: &mut VectorNetwork, idx: &[usize], heal: bool) {
}
net.sanitize();
reopen_loops(net);
if net.vertices.len() != before {
close_bridged_loops(net);
}
}
/// Handles for the curve that heals over deleted point `vi` between `(vertex, handle)` ends `o1`
@@ -470,6 +478,24 @@ fn reopen_loops(net: &mut VectorNetwork) {
}
}
/// After points or segments were removed: an open loop whose two ends a segment in no loop already
/// joins (a bridge drawn between two points before what lay between them was deleted) closes
/// through that segment, at the loop's end so its winding is kept, as `connect` closes it (#1951).
/// Every loop takes what [`VectorNetwork::closing_segment`] gives it against one snapshot of the
/// network, which is what rounding walks: two fills that shared the removed border share the
/// bridge, as they shared the border. Only a removal can open a loop at two points a segment
/// already joins (a cut's ends are new points), so only `delete_vertices` and `remove_segments`
/// run this, and only when they removed something.
fn close_bridged_loops(net: &mut VectorNetwork) {
let index = net.loop_index();
let closings: Vec<Option<usize>> = net.regions.iter().flat_map(|r| r.loops.iter()).map(|lp| net.closing_segment(lp, &index)).collect();
for (lp, closing) in net.regions.iter_mut().flat_map(|r| r.loops.iter_mut()).zip(closings) {
if let Some(closing) = closing {
lp.push(closing);
}
}
}
/// Cut a segment at `t` into two open ends at the same spot. Returns the two end vertices.
pub fn cut_segment(net: &mut VectorNetwork, seg: usize, t: f64) -> Option<(usize, usize)> {
let mid = split_segment(net, seg, t)?;
@@ -1107,6 +1133,24 @@ mod tests {
assert!(d.vertices.iter().all(|v| v.x.is_finite() && v.y.is_finite()));
}
/// A curved bridge between two points of a ring, then the point between them deleted (Delete,
/// no healing): the region's loop closes through the bridge, so the fill follows its curve and
/// is as long as the stroke (#1951).
#[test]
fn deleting_a_point_behind_a_curved_bridge_keeps_the_bridge_in_the_loop() {
use figcraft_geom::BezPath;
use figcraft_geom::kurbo::ParamCurveArclen;
let pts = [(0.0, 0.0), (50.0, 0.0), (100.0, 0.0), (100.0, 50.0), (100.0, 100.0), (0.0, 100.0)].map(|(x, y)| Point::new(x, y));
let mut n = VectorNetwork::from_points(&pts, true);
connect(&mut n, 1, 3, (25.0, 25.0), (-25.0, -25.0)).unwrap();
assert_eq!((n.segments.len(), n.regions[0].loops[0].len()), (7, 6), "the ring kept");
delete_vertices(&mut n, &[2], false);
assert_eq!((n.segments.len(), n.regions[0].loops[0].len()), (5, 5), "the loop holds the bridge: {:?}", n.regions);
let length = |p: &BezPath| p.segments().map(|s| s.arclen(1e-9)).sum::<f64>();
let (fill, stroke) = (length(&n.fill_path()), length(&n.stroke_path()));
assert!((fill - stroke).abs() < 1e-6, "fill {fill} and stroke {stroke} follow the same outline");
}
#[test]
fn lasso_polygon() {
let pts = [Point::new(5.0, 5.0), Point::new(50.0, 50.0), Point::new(15.0, 5.0)];
@@ -1175,4 +1219,169 @@ mod tests {
assert!(ends.iter().any(|x| (x - (centre.x - 0.5)).abs() < 0.01), "{ends:?}");
assert!(ends.iter().any(|x| (x - (centre.x + 0.5)).abs() < 0.01), "{ends:?}");
}
/// An open chain of four segments over five points whose two ends a fifth segment (in no
/// loop) joins, as a bridge drawn before the point between them was deleted.
fn bridged_open_chain() -> VectorNetwork {
let corners = [
Point::new(0.0, 0.0),
Point::new(525.0, 525.0),
Point::new(0.0, 525.0),
Point::new(333.0, 0.0),
Point::new(525.0, 183.0),
Point::new(900.0, 900.0),
];
let mut n = VectorNetwork::from_points(&corners, false);
n.segments = [(0, 3), (1, 2), (2, 0), (4, 1), (3, 4)].iter().map(|&(a, b)| Segment::line(a, b)).collect();
n.regions.clear();
for v in &mut n.vertices {
v.radius = 125.0;
}
n
}
#[test]
fn regions_over_one_open_chain_all_close_through_the_joining_segment() {
use figcraft_geom::BezPath;
use figcraft_geom::kurbo::ParamCurveArclen;
let mut n = bridged_open_chain();
for _ in 0..2 {
n.regions.push(Region { loops: vec![vec![3, 1, 2, 0]], winding: WindingRule::NonZero, fills: None, ..Default::default() });
}
delete_vertices(&mut n, &[5], false);
for (i, r) in n.regions.iter().enumerate() {
assert_eq!(r.loops[0], vec![3, 1, 2, 0, 4], "region {i} closes through the segment: {:?}", n.regions);
}
let length = |path: &BezPath| path.segments().map(|s| s.arclen(1e-9)).sum::<f64>();
let stroke = length(&n.stroke_path());
let rounded = n.rounded().unwrap();
for i in 0..2 {
let fill = length(&rounded.region_path(i).unwrap());
assert!((fill - stroke).abs() < 1e-6, "region {i}: fill {fill} and stroke {stroke} trace the same outline");
}
}
/// Many independent open chains, each with its own closing segment, all close through it.
#[test]
fn many_open_chains_each_close_through_their_own_segment() {
let one = bridged_open_chain();
let (nv, ns) = (one.vertices.len(), one.segments.len());
let mut n = VectorNetwork::default();
for (i, dx) in (0..50usize).zip((0..50u32).map(|k| f64::from(k) * 1000.0)) {
n.vertices.extend(one.vertices.iter().map(|v| Vertex { x: v.x + dx, ..*v }));
n.segments.extend(one.segments.iter().map(|s| Segment { a: s.a + i * nv, b: s.b + i * nv, ..*s }));
let base = i * ns;
n.regions.push(Region {
loops: vec![vec![base + 3, base + 1, base + 2, base]],
winding: WindingRule::NonZero,
fills: None,
..Default::default()
});
}
close_bridged_loops(&mut n);
for (i, r) in n.regions.iter().enumerate() {
assert_eq!(r.loops[0].last(), Some(&(i * ns + 4)), "chain {i} closes through its own segment");
}
}
#[test]
fn a_segment_joining_two_different_chains_closes_both() {
let mut n = bridged_open_chain();
n.regions.push(Region { loops: vec![vec![3, 1, 2, 0]], winding: WindingRule::NonZero, fills: None, ..Default::default() });
n.regions.push(Region { loops: vec![vec![0, 2, 1, 3]], winding: WindingRule::NonZero, fills: None, ..Default::default() });
delete_vertices(&mut n, &[5], false);
assert_eq!(n.regions[0].loops[0].len(), 5);
assert_eq!(n.regions[1].loops[0].len(), 5, "the same segment set is the same chain");
let mut m = bridged_open_chain();
m.segments.push(Segment::line(1, 2));
m.regions.push(Region { loops: vec![vec![3, 1, 2, 0]], winding: WindingRule::NonZero, fills: None, ..Default::default() });
m.regions.push(Region { loops: vec![vec![3, 5, 2, 0]], winding: WindingRule::NonZero, fills: None, ..Default::default() });
let drawn = m.rounded().unwrap();
delete_vertices(&mut m, &[5], false);
assert_eq!(m.regions[0].loops[0], vec![3, 1, 2, 0, 4], "{:?}", m.regions);
assert_eq!(m.regions[1].loops[0], vec![3, 5, 2, 0, 4], "different chains share the segment too: {:?}", m.regions);
let after = m.rounded().unwrap();
for i in 0..2 {
assert_eq!(after.region_path(i).unwrap(), drawn.region_path(i).unwrap(), "region {i} is stored as rounding drew it");
}
}
/// Two fills that shared a border, a bridge drawn between the border's ends, then the border's
/// middle point deleted: both fills close through the bridge (#1951).
#[test]
fn two_fills_that_shared_a_deleted_border_both_close_through_the_bridge() {
use figcraft_geom::Shape;
let pts = [(0.0, 0.0), (50.0, 0.0), (100.0, 0.0), (100.0, 100.0), (50.0, 100.0), (0.0, 100.0)].map(|(x, y)| Point::new(x, y));
let mut n = VectorNetwork::from_points(&pts, true);
n.vertices.push(Vertex::new(50.0, 50.0));
n.segments.push(Segment::line(1, 6));
n.segments.push(Segment::line(6, 4));
n.regions.clear();
for lp in [vec![0, 6, 7, 4, 5], vec![1, 2, 3, 7, 6]] {
n.regions.push(Region { loops: vec![lp], winding: WindingRule::NonZero, fills: None, ..Default::default() });
}
connect(&mut n, 1, 4, (30.0, 30.0), (30.0, -30.0)).unwrap();
delete_vertices(&mut n, &[6], false);
assert_eq!(n.regions[0].loops[0], vec![4, 5, 0, 6]);
assert_eq!(n.regions[1].loops[0], vec![1, 2, 3, 6], "the second fill closes through the bridge too");
let area = |i: usize| n.region_path(i).unwrap().area().abs();
assert!((area(0) + area(1) - 10_000.0).abs() < 1e-6, "the fills meet along the bridge");
}
#[test]
fn removing_nothing_leaves_an_open_loop_alone() {
let mut n = bridged_open_chain();
n.regions.push(Region { loops: vec![vec![3, 1, 2, 0]], winding: WindingRule::NonZero, fills: None, ..Default::default() });
let before = n.clone();
remove_segments(&mut n, &[]);
assert_eq!(n, before);
remove_segments(&mut n, &[99]);
assert_eq!(n, before);
delete_vertices(&mut n, &[], false);
assert_eq!(n, before);
delete_vertices(&mut n, &[99], true);
assert_eq!(n, before);
delete_vertices(&mut n, &[5], false);
assert_eq!(n.regions[0].loops[0], vec![3, 1, 2, 0, 4]);
}
/// The same bridge as the curved-bridge test, reached by removing segments.
#[test]
fn removing_segments_behind_a_curved_bridge_keeps_the_bridge_in_the_loop() {
let pts = [(0.0, 0.0), (50.0, 0.0), (100.0, 0.0), (100.0, 50.0), (100.0, 100.0), (0.0, 100.0)].map(|(x, y)| Point::new(x, y));
let mut n = VectorNetwork::from_points(&pts, true);
connect(&mut n, 1, 3, (25.0, 25.0), (-25.0, -25.0)).unwrap();
remove_segments(&mut n, &[1, 2]);
assert_eq!((n.segments.len(), n.regions[0].loops[0].len()), (5, 5));
assert_eq!(n.regions[0].loops[0], vec![1, 2, 3, 0, 4]);
}
#[test]
fn many_open_loops_with_no_closing_segment_round_as_before() {
let mut n = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0)], false);
n.segments.clear();
n.vertices.clear();
for k in 0..40 {
let x = f64::from(k) * 100.0;
let base = n.vertices.len();
for p in [(x, 0.0), (x + 50.0, 0.0), (x + 50.0, 50.0), (x, 50.0)] {
n.vertices.push(Vertex { radius: 8.0, ..Vertex::new(p.0, p.1) });
}
let first = n.segments.len();
// Three sides of a square, an open loop with nothing to close it.
for (a, b) in [(0, 1), (1, 2), (2, 3)] {
n.segments.push(Segment::line(base + a, base + b));
}
n.regions.push(Region {
loops: vec![vec![first, first + 1, first + 2]],
winding: WindingRule::NonZero,
fills: None,
..Default::default()
});
}
let r = n.rounded().unwrap();
assert_eq!(r.regions.len(), 40);
// Each loop gained only its two inner corners' arcs (two corners, one arc each).
assert!(r.regions.iter().all(|reg| reg.loops[0].len() == 5), "{:?}", r.regions[0]);
}
}
@@ -659,6 +659,27 @@ fn a_rounded_chamfer_made_other_ways_fills_along_its_arcs() {
fill_traces_stroke(&s, id, "drawn with the pen");
}
/// The reporter's sequence (#1951): a bridge drawn between the two new points while the corner is
/// still there (both have two segments, so the region keeps its ring), then the corner deleted with
/// Delete. The region's loop must close through the bridge, so the rounded fill traces the stroke.
#[test]
fn deleting_the_corner_behind_a_pen_bridge_closes_the_loop_through_the_bridge() {
let mut s = Session::new();
let id = rect(&mut s);
ex(&mut s, "vector.edit.addVertex", json!({"segment": 0, "t": 0.5}));
ex(&mut s, "vector.edit.addVertex", json!({"segment": 1, "t": 0.5}));
let n = net(&s, id);
let (top, right) = (vertex_at(&n, 50.0, 0.0), vertex_at(&n, 100.0, 50.0));
ex(&mut s, "vector.edit.connect", json!({"a": top, "b": right}));
let n = net(&s, id);
assert_eq!((n.segments.len(), n.regions[0].loops[0].len()), (7, 6), "bridged, the ring kept: {:?}", n.regions);
let corner = vertex_at(&n, 100.0, 0.0);
ex(&mut s, "vector.edit.select", json!({"vertices": [corner]}));
ex(&mut s, "vector.edit.delete", json!({}));
ex(&mut s, "vector.edit.cornerRadius", json!({"radius": 43, "vertices": [0, 1, 2, 3, 4]}));
fill_traces_stroke(&s, id, "bridged, then the corner deleted");
}
/// The rounded chamfered rectangle `id` (five points on the origin's 100×100 box, the top-right
/// corner cut off between (50, 0) and (100, 50)): one region with every segment in its loop, and a
/// fill that keeps off every sharp point and traces the same outline as the stroke.
+1
View File
@@ -291,6 +291,7 @@ pub static MENU: &[MenuItem] = &[
m!("Tools", "Pencil", "tool.pencil"),
m!("Tools", "Text", "tool.text"),
m!("Tools", "Comment", "tool.comment"),
m!("Tools", "Annotation", "tool.annotation"),
m!("Tools", "Shape builder", "tool.shapeBuilder"),
m!("Tools", "Actions", "app.actions"),
];
+6 -3
View File
@@ -26,8 +26,8 @@ pub(crate) use export::FORBIDDEN_FILE_NAME_CHARS;
use export::add_member;
pub(super) use export::export_setting;
pub use export::{
ExportJob, copy_png, drawn_layers, export_area, export_bytes, export_bytes_with, export_jobs, export_scale, exportable_layers, fig_bytes,
frames_pdf, jobs_for, penpot_bytes, safe_file_name,
ExportJob, backdrop_layers, copy_png, drawn_layers, export_area, export_bytes, export_bytes_with, export_jobs, export_scale, exportable_layers,
fig_bytes, frames_pdf, jobs_for, penpot_bytes, safe_file_name,
};
use export::{export, export_frames_pdf, export_layers, pdf_bytes};
use load::font_report;
@@ -409,7 +409,10 @@ pub fn export_file(doc: &Document, ids: &[NodeId], setting: &ExportSetting, scal
Ok((pdf_bytes(doc, &groups, &setting.print, (!own_area).then_some(area))?, Vec::new()))
}
_ => {
let mut pix = figcraft_render::render_area(doc, &drawn, area, scale)
// Layers exported on their own still read the page beneath them for a background blur
// or glass (#2213); a "contents only" off export already draws the whole page.
let page = if setting.contents_only { backdrop_layers(doc, ids) } else { Vec::new() };
let mut pix = figcraft_render::render_area_over(doc, &drawn, &page, area, scale)
.ok_or_else(|| EngineError::Other("render failed (empty or too large)".into()))?;
// The render is in the document's profile; the setting's profile converts the pixels
// (out-of-gamut colors clip) and tags the file.
+6
View File
@@ -55,6 +55,12 @@ pub fn drawn_layers(doc: &Document, ids: &[NodeId], setting: &ExportSetting) ->
crate::cmd::edit::z_sorted(doc, &crate::cmd::edit::roots(doc, &own))
}
/// The top-level layers of the page of `ids`, bottom to top: what a background blur or glass in an
/// export of `ids` reads from (empty when `ids` are on no page).
pub fn backdrop_layers(doc: &Document, ids: &[NodeId]) -> Vec<NodeId> {
ids.first().and_then(|i| doc.page_of(*i)).and_then(|p| doc.node(p)).map(|p| p.children.clone()).unwrap_or_default()
}
/// The frame or group a slice sits in: None on the page or in a section.
fn slice_container(doc: &Document, slice: NodeId) -> Option<NodeId> {
let parent = doc.node(slice)?.parent?;
+6 -1
View File
@@ -54,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"style.bind",
"Bind variable in style",
"{style, field, variable? (id or name; none: unbind)} bind a variable to a field of the style's value: a paint style's `fills/<i>/color` (or `fill`: its first paint) and `fills/<i>/stops/<j>/color`, an effect style's `effects/<i>/<color|radius|spread|offsetX|offsetY|startRadius>` (glass: `lightAngle|lightIntensity|refraction|depth|dispersion|radius|splay`), a grid style's `layoutGrids/<i>/<size|count|gutter|offset>`, a text style's `fontSize|lineHeight|letterSpacing` (numbers, px) and `fontFamily|fontStyle` (strings). Every layer using the style shows the field resolved in its own modes; the style keeps it resolved in the default modes",
"{style, field, variable? (id or name; none: unbind)} bind a variable to a field of the style's value: a paint style's `fills/<i>/color` (or `fill`: its first paint) and `fills/<i>/stops/<j>/color`, an effect style's `effects/<i>/<color|radius|spread|offsetX|offsetY|startRadius>` (glass: `lightAngle|lightIntensity|refraction|depth|dispersion|radius|splay`), a grid style's `layoutGrids/<i>/<size|count|gutter|offset>`, a text style's `fontSize|lineHeight|letterSpacing|fontWeight` (numbers; line height and letter spacing in px, as the reference tool applies them; a weight picks the family's face nearest it and the `wght` axis of a variable font) and `fontFamily|fontStyle` (strings; a weight bound as a number replaces a bound style name and the reverse). Every layer using the style shows the field resolved in its own modes; the style keeps it resolved in the default modes",
true,
bind_style
),
@@ -520,6 +520,11 @@ fn bind_style(s: &mut Session, p: &Value) -> Result<Value> {
{
rebind_paint_alpha(paints, &mut [], &key, true);
}
match key.as_str() {
"fontWeight" => style.bound.remove("fontStyle"),
"fontStyle" => style.bound.remove("fontWeight"),
_ => None,
};
style.bound.insert(key, id);
}
None => {
+27 -1
View File
@@ -235,6 +235,26 @@ pub(crate) fn tool(s: &mut Session, t: ToolId) -> Result<Value> {
Ok(json!({"tool": t.id()}))
}
/// Shift+T and choosing the Annotation tool: with a layer selected (or named by `id`/`ids`) the UI
/// is asked for its note at once; in the tool, a press on a layer selects it and asks (the engine
/// never writes the note). A named target is checked first: one that isn't a layer in the document
/// is an error and the tool stays as it was.
fn annotation(s: &mut Session, p: &Value) -> Result<Value> {
let named = p.get("id").is_some_and(|v| !v.is_null()) || p.get("ids").is_some_and(|v| !v.is_null());
let first = crate::params::targets(s, p).into_iter().find(|i| s.doc().is_ok_and(|d| d.node(*i).is_some_and(|n| !n.is_page())));
if named && first.is_none() {
return Err(bad("tool.annotation", "`id` must name a layer in this document (not a page)".to_string()));
}
let r = tool(s, ToolId::Annotation)?;
if let Some(id) = first {
if named {
crate::cmd::edit::select_and_page(s, vec![id])?;
}
s.request_ui("annotation.new", json!({"id": id}));
}
Ok(r)
}
/// The View menu's commands and their parameters.
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -528,7 +548,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"tool.set",
"Choose tool",
"{tool: move|scale|hand|frame|section|slice|rectangle|line|arrow|ellipse|polygon|star|pen|pencil|text|comment|shapeBuilder | on boards: sticky|shape|connector|marker|highlighter|eraser|stamp}",
"{tool: move|scale|hand|frame|section|slice|rectangle|line|arrow|ellipse|polygon|star|pen|pencil|text|comment|annotation|shapeBuilder | on boards: sticky|shape|connector|marker|highlighter|eraser|stamp}",
false,
|s, p| {
let t = req_string("tool.set", p, "tool")?;
@@ -536,6 +556,9 @@ pub fn specs() -> Vec<CommandSpec> {
if t.on_boards() {
crate::board::require(s, &format!("tool.{}", t.id()))?;
}
if t == ToolId::Annotation {
return annotation(s, &json!({}));
}
tool(s, t)
}
),
@@ -638,6 +661,9 @@ pub fn specs() -> Vec<CommandSpec> {
tool(s, ToolId::TextOnPath)
}),
spec!("tool.comment", "Comment", "{}", false, |s, _| tool(s, ToolId::Comment)),
// Shift+T: with a layer selected (or an `id`) the UI is asked for its note at once; in the
// tool, a press on a layer selects it and asks (the engine never writes the note).
spec!("tool.annotation", "Annotation", "{id?|ids?}", false, annotation),
spec!("tool.shapeBuilder", "Shape builder", "{}", false, |s, _| tool(s, ToolId::ShapeBuilder)),
// `apply: false` is for the UI colour picker: the eyedropper only samples (into
// `ToolState::picked`) and the picker writes the colour to the paint it edits.
+1
View File
@@ -107,6 +107,7 @@ fn each_tool_has_its_cursor_by_modifier_and_what_is_under_the_pointer() {
(ToolId::TextOnPath, NONE, ON_SQUARE_EDGE, Text, "the I-beam over a shape's path: a click puts text on it"),
(ToolId::Pen, NONE, EMPTY, Pen(PenCursor::New), "the pen: a new point"),
(ToolId::Comment, NONE, EMPTY, Comment, "the comment pin"),
(ToolId::Annotation, NONE, ON_SQUARE, Crosshair, "the annotation tool marks the layer it is pressed on"),
(ToolId::Eyedropper, NONE, ON_SQUARE, Eyedropper, "the eyedropper"),
];
for (tool, m, at, want, why) in rows {
+1 -1
View File
@@ -230,7 +230,7 @@ pub fn dev_mode_allows(id: &str) -> bool {
// Edits recorded as one grouped undo step, so their spec says no history of its own.
const GROUPED: [&str; 1] = ["data.fill"];
match id.strip_prefix("tool.") {
Some(t) => matches!(t, "move" | "hand" | "comment"),
Some(t) => crate::tools::ToolId::from_id(t).is_some_and(crate::tools::ToolId::works_in_dev_mode),
None if id == "file.revert" || GROUPED.contains(&id) => false,
None => OWN.iter().any(|p| id.starts_with(p)) || crate::cmd::find_command(id).is_none_or(|c| !c.history),
}
@@ -99,6 +99,102 @@ fn the_dev_mode_canvas_selects_and_pans_but_never_edits() {
assert!(s.doc().unwrap().node(r).is_some_and(|n| n.transform.as_coeffs()[4] > 100.0), "moved");
}
/// The `annotation.new` requests queued so far, as the layer ids they name.
fn note_requests(s: &Session) -> Vec<NodeId> {
s.ui_requests.iter().filter(|r| r.kind == "annotation.new").filter_map(|r| crate::params::id_of(&r.data["id"])).collect()
}
fn click(s: &mut Session, x: f64, y: f64) {
s.pointer(&ev(PointerKind::Down, x, y)).unwrap();
s.pointer(&ev(PointerKind::Up, x, y)).unwrap();
}
#[test]
fn the_annotation_tool_picks_a_layer_and_asks_for_its_note() {
for dev_mode in [true, false] {
let mut s = Session::new();
let f = frame(&mut s, 0.0, "Screen");
let r = rect(&mut s, f, 20.0);
s.tool.dev_mode = dev_mode;
ex(&mut s, "select.set", json!({"ids": []}));
ex(&mut s, "tool.annotation", json!({}));
assert_eq!(s.tool.tool, ToolId::Annotation);
assert!(note_requests(&s).is_empty(), "nothing selected: no note is asked for yet");
let (doc_before, undo_before) = (s.doc().unwrap().clone(), s.undo_labels(1000).len());
// A press on empty canvas does nothing.
click(&mut s, 1000.0, 1000.0);
assert!(note_requests(&s).is_empty(), "empty canvas asks for nothing (dev_mode {dev_mode})");
// A press on the rectangle selects it and asks for its note.
click(&mut s, 40.0, 40.0);
assert_eq!(s.selection(), vec![r], "dev_mode {dev_mode}");
assert_eq!(note_requests(&s), vec![r], "dev_mode {dev_mode}");
assert_eq!(s.tool.tool, ToolId::Annotation, "the tool stays until V or Escape");
// The tool writes nothing.
assert_eq!(*s.doc().unwrap(), doc_before);
assert_eq!(s.undo_labels(1000).len(), undo_before);
}
// The keyboard flow: with a layer selected, choosing the tool asks at once.
let mut s = Session::new();
let f = frame(&mut s, 0.0, "Screen");
let r = rect(&mut s, f, 20.0);
ex(&mut s, "select.set", json!({"ids": [r]}));
ex(&mut s, "tool.annotation", json!({}));
assert_eq!(note_requests(&s), vec![r]);
// And with an `id`.
let mut s = Session::new();
let f = frame(&mut s, 0.0, "Screen");
let r = rect(&mut s, f, 20.0);
ex(&mut s, "tool.annotation", json!({"id": r}));
assert_eq!(note_requests(&s), vec![r]);
assert_eq!(s.selection(), vec![r]);
}
/// A named target that isn't a layer (a page, an unknown id) is an error, and the tool is left as it
/// was; several targets pick the first layer among them and select it.
#[test]
fn the_annotation_tool_checks_the_layer_it_is_named_for_before_changing_anything() {
let mut s = Session::new();
let f = frame(&mut s, 0.0, "Screen");
let r = rect(&mut s, f, 20.0);
let page = s.page().unwrap();
assert!(s.execute("tool.annotation", &json!({"id": page})).is_err(), "a page is not a layer");
assert!(s.execute("tool.annotation", &json!({"id": "9999:9999"})).is_err(), "no such layer");
assert!(s.execute("tool.annotation", &json!({"ids": []})).is_err(), "nothing named");
assert_eq!(s.tool.tool, ToolId::Move, "the tool did not change");
assert!(note_requests(&s).is_empty());
// The page first, then the rectangle: the rectangle is the target, and selected.
ex(&mut s, "tool.annotation", json!({"ids": [page, r]}));
assert_eq!((s.tool.tool, s.selection(), note_requests(&s)), (ToolId::Annotation, vec![r], vec![r]));
}
/// Choosing the tool with `tool.set` (the toolbar) asks for the selected layer's note like Shift+T.
#[test]
fn choosing_the_annotation_tool_from_the_toolbar_asks_for_the_selected_layers_note() {
let mut s = Session::new();
let f = frame(&mut s, 0.0, "Screen");
let r = rect(&mut s, f, 20.0);
ex(&mut s, "select.set", json!({"ids": [r]}));
ex(&mut s, "tool.set", json!({"tool": "annotation"}));
assert_eq!((s.tool.tool, note_requests(&s)), (ToolId::Annotation, vec![r]));
}
/// A drag with the tool never moves the layer it starts on, in Developer Mode or out of it.
#[test]
fn dragging_with_the_annotation_tool_moves_nothing() {
for dev_mode in [true, false] {
let mut s = Session::new();
let f = frame(&mut s, 0.0, "Screen");
rect(&mut s, f, 20.0);
s.tool.dev_mode = dev_mode;
ex(&mut s, "select.set", json!({"ids": []}));
ex(&mut s, "tool.annotation", json!({}));
let (doc_before, undo_before) = (s.doc().unwrap().clone(), s.undo_labels(1000).len());
drag(&mut s, (40.0, 40.0), (140.0, 90.0));
assert_eq!(*s.doc().unwrap(), doc_before, "dev_mode {dev_mode}");
assert_eq!(s.undo_labels(1000).len(), undo_before, "dev_mode {dev_mode}");
}
}
#[test]
fn dev_mode_runs_its_own_edits_and_refuses_design_edits_from_menus_and_shortcuts() {
for id in [
@@ -121,6 +217,7 @@ fn dev_mode_runs_its_own_edits_and_refuses_design_edits_from_menus_and_shortcuts
"tool.move",
"tool.hand",
"tool.comment",
"tool.annotation",
"inspect.code",
] {
assert!(dev_mode_allows(id), "{id} runs in Dev Mode");
+161
View File
@@ -385,6 +385,167 @@ fn copy_as_png_svg_and_code() {
assert!(css.contains("background: #FF0000;"), "Copy as CSS is the layer's CSS from Inspect");
}
/// A red square with a fill-less pane over its right part, holding `effect` (#2213).
fn pane_scene(effect: EffectKind) -> (figcraft_doc::Document, NodeId, NodeId) {
use figcraft_doc::{Document, NodeKind};
let mut d = Document::new("blur-export");
let page = d.first_page().unwrap();
let under = d.create(page, NodeKind::Rectangle, "Under", 0.0, 0.0, 120.0, 120.0).unwrap();
d.get_mut(under).unwrap().fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::from_hex("E03030").unwrap())];
let pane = d.create(page, NodeKind::Rectangle, "Glass", 60.0, 20.0, 120.0, 80.0).unwrap();
d.get_mut(pane).unwrap().fills = vec![];
d.get_mut(pane).unwrap().effects = vec![Effect { kind: effect, visible: true }];
(d, under, pane)
}
/// The PNG export of `ids` as the Export command makes it, and what the canvas draws there.
fn export_vs_canvas(d: &figcraft_doc::Document, ids: &[NodeId]) -> (image::RgbaImage, image::RgbaImage) {
let setting = figcraft_doc::ExportSetting::default();
let page = d.first_page().unwrap();
let area = crate::cmd::file::export_area(d, ids, &setting).unwrap();
let canvas = figcraft_render::render_area(d, &d.get(page).unwrap().children.clone(), area, 1.0).unwrap();
let canvas = image::RgbaImage::from_raw(canvas.width(), canvas.height(), figcraft_render::to_rgba8(&canvas)).unwrap();
let (bytes, _) = crate::cmd::file::export_file(d, ids, &setting, 1.0).unwrap();
(pixels(&bytes), canvas)
}
/// #2213: a layer with a background blur exported on its own shows the same pixels as the canvas
/// shows for that area: the blur reads what lies under it, so the export must draw the layers
/// beneath it as the backdrop (clipped to the layer) rather than blurring nothing.
#[test]
fn exporting_a_background_blur_layer_keeps_the_blurred_backdrop() {
let (d, _, pane) = pane_scene(EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(12.0)));
let (exported, canvas) = export_vs_canvas(&d, &[pane]);
// The pane's left half sits over the red square, which ends at x = 120 (60 px into the pane).
assert_ne!(canvas.get_pixel(60, 40).0, [0, 0, 0, 0], "the canvas draws the blurred backdrop");
assert_eq!(exported.get_pixel(60, 40), canvas.get_pixel(60, 40), "export lost the backdrop the blur reads");
assert_eq!(exported.dimensions(), (120, 80), "the pane's own bounds");
}
/// #2213, as reported and as measured in Figma (2026-11-10): the pane inside a frame, exported on
/// its own, keeps the frame's fill and the blurred layer beneath it, opaque.
#[test]
fn a_blurred_pane_inside_a_frame_keeps_the_frame_and_the_layer_beneath() {
use figcraft_doc::{Document, NodeKind};
let mut d = Document::new("blur-export-frame");
let page = d.first_page().unwrap();
let art = d.create(page, NodeKind::Frame, "Art", 0.0, 0.0, 300.0, 200.0).unwrap();
d.get_mut(art).unwrap().fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::from_hex("FFFFFF").unwrap())];
let under = d.create(art, NodeKind::Rectangle, "Under", 0.0, 0.0, 120.0, 120.0).unwrap();
d.get_mut(under).unwrap().fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::from_hex("E03030").unwrap())];
let pane = d.create(art, NodeKind::Rectangle, "Glass", 60.0, 20.0, 120.0, 80.0).unwrap();
d.get_mut(pane).unwrap().fills = vec![];
d.get_mut(pane).unwrap().effects = vec![Effect { kind: EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(12.0)), visible: true }];
let (exported, canvas) = export_vs_canvas(&d, &[pane]);
assert_eq!(exported.get_pixel(60, 40), canvas.get_pixel(60, 40), "the blurred square under the pane");
assert_eq!(exported.get_pixel(100, 40).0[3], 255, "the frame's white behind the pane, opaque");
}
/// #2213: the same for Glass.
#[test]
fn exporting_a_glass_layer_keeps_the_backdrop_it_bends() {
let (d, _, pane) = pane_scene(EffectKind::Glass(figcraft_doc::Glass::default()));
let (exported, canvas) = export_vs_canvas(&d, &[pane]);
assert_ne!(canvas.get_pixel(30, 40).0, [0, 0, 0, 0]);
assert_eq!(exported.get_pixel(30, 40), canvas.get_pixel(30, 40), "export lost the backdrop the glass reads");
assert_eq!(exported.get_pixel(60, 40), canvas.get_pixel(60, 40));
}
/// Measured in Figma 2026-11-10: inside the pane's bounds the export is opaque where the backdrop
/// is (the blurred red), and the layers over the pane stay out.
#[test]
fn a_blur_export_is_opaque_inside_the_pane_and_leaves_layers_above_out() {
let (mut d, _, pane) = pane_scene(EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(12.0)));
let page = d.first_page().unwrap();
let above = d.create(page, figcraft_doc::NodeKind::Rectangle, "Above", 0.0, 0.0, 200.0, 200.0).unwrap();
d.get_mut(above).unwrap().fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::from_hex("0000FF").unwrap())];
let (exported, _) = export_vs_canvas(&d, &[pane]);
let left = exported.get_pixel(10, 40).0;
assert_eq!(left[3], 255, "red under the pane: opaque");
assert!(left[0] > 150 && left[2] < 60, "red, not the blue layer above: {left:?}");
// The far right of the pane has no square under it: nothing to read, nothing drawn.
assert_eq!(exported.get_pixel(110, 40).0[3], 0);
}
/// Guard: a layer without backdrop effects over another still exports alone ("contents only").
#[test]
fn a_layer_without_backdrop_effects_still_leaves_the_layers_below_out() {
let (mut d, _, pane) = pane_scene(EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(0.0)));
d.get_mut(pane).unwrap().effects.clear();
let (exported, _) = export_vs_canvas(&d, &[pane]);
assert!(exported.pixels().all(|p| p.0 == [0, 0, 0, 0]), "a fill-less pane alone exports clear");
}
#[test]
fn node_set_takes_back_the_export_settings_node_get_returns() {
use figcraft_doc::{ExportColorProfile, ExportFormat, ExportSetting, PrintSettings, VdCompat};
// Every multi-word field (and `quality`) away from its default.
let expected = ExportSetting {
format: ExportFormat::Jpg,
suffix: "@3x".into(),
print: PrintSettings { bleed: 9.0, crop_marks: true, bleed_marks: true, cmyk: true },
contents_only: false,
include_bounding_box: true,
svg_outline_text: false,
svg_id_attribute: true,
svg_simplify_stroke: false,
vd_compat: VdCompat::Exact,
color_profile: ExportColorProfile::DisplayP3,
quality: 55,
..Default::default()
};
let default = ExportSetting::default();
for (field, differs) in [
("print", expected.print != default.print),
("contents_only", expected.contents_only != default.contents_only),
("include_bounding_box", expected.include_bounding_box != default.include_bounding_box),
("svg_outline_text", expected.svg_outline_text != default.svg_outline_text),
("svg_id_attribute", expected.svg_id_attribute != default.svg_id_attribute),
("svg_simplify_stroke", expected.svg_simplify_stroke != default.svg_simplify_stroke),
("vd_compat", expected.vd_compat != default.vd_compat),
("color_profile", expected.color_profile != default.color_profile),
("quality", expected.quality != default.quality),
] {
assert!(differs, "{field} must be non-default in the expected setting");
}
let mut s = Session::new();
let a = rect(&mut s, 0.0, 0.0, 40.0, 20.0, "#FF0000", None);
let stored = |s: &Session| s.doc().unwrap().node(a).unwrap().exports.clone();
ex(
&mut s,
"export.add",
json!({"ids": [a], "setting": {
"format": "jpg", "suffix": "@3x", "bleed": 9, "cropMarks": true, "bleedMarks": true, "cmyk": true,
"contentsOnly": false, "includeBoundingBox": true, "svgOutlineText": false, "svgIdAttribute": true,
"svgSimplifyStroke": false, "vdCompat": "exact", "colorProfile": "displayP3", "quality": 55,
}}),
);
assert_eq!(stored(&s), vec![expected.clone()], "export.add stores every field");
// What node.get returns goes back through node.set unchanged.
let got = ex(&mut s, "node.get", json!({"id": a}))["exports"].clone();
set(&mut s, &[a], json!({"exports": []}));
assert!(stored(&s).is_empty());
set(&mut s, &[a], json!({"exports": got}));
assert_eq!(stored(&s), vec![expected.clone()], "node.set stores what node.get returned, unchanged");
// The snake_case spelling is taken too.
set(&mut s, &[a], json!({"exports": []}));
set(
&mut s,
&[a],
json!({"exports": [{
"format": "jpg", "suffix": "@3x",
"print": {"bleed": 9, "crop_marks": true, "bleed_marks": true, "cmyk": true},
"contents_only": false, "include_bounding_box": true, "svg_outline_text": false, "svg_id_attribute": true,
"svg_simplify_stroke": false, "vd_compat": "exact", "color_profile": "displayP3", "quality": 55,
}]}),
);
assert_eq!(stored(&s), vec![expected], "node.set takes the snake_case keys");
}
#[test]
fn export_rejects_an_invalid_layer_id_instead_of_exporting_the_page() {
let mut s = Session::new();
+1
View File
@@ -192,6 +192,7 @@ pub static KEYS: &[Binding] = &[
k(Tools, "tool.pencil", &["Shift+P"]),
k(Tools, "tool.text", &["T"]),
k(Tools, "tool.comment", &["C"]),
k(Tools, "tool.annotation", &["Shift+T"]),
k(Tools, "tool.shapeBuilder", &["Shift+M"]),
// Board tools (on boards only), on keys nothing else uses.
k(Tools, "tool.sticky", &["Q"]),
+2
View File
@@ -206,6 +206,8 @@ fn tool_keys_come_from_the_keymap() {
assert_eq!(ToolId::Eyedropper.shortcut(), "I");
assert_eq!(ToolId::Polygon.shortcut(), "");
assert_eq!(crate::catalog::shortcut("tool.text"), Some("T"));
assert_eq!(ToolId::Annotation.shortcut(), "Shift+T");
assert_eq!(crate::catalog::shortcut("tool.annotation"), Some("Shift+T"));
}
#[test]
+2 -1
View File
@@ -110,6 +110,7 @@ static ROWS: &[Row] = &[
doc(Tools, "Pencil tool", &["Shift+P"], &[], Bound("tool.pencil"), ""),
doc(Tools, "Text tool", &["T"], &[], Bound("tool.text"), ""),
doc(Tools, "Comment mode", &["C"], &[], Bound("tool.comment"), ""),
doc(Tools, "Annotation tool", &["Shift+T"], &[], Bound("tool.annotation"), "Accessibility at Figma, Add an annotation; Developer Mode"),
doc(Tools, "Hand tool while held", &["Space"], &[], Gesture("Pan the canvas"), "held, not pressed"),
doc(Tools, "Pan with the arrows when nothing is selected (Shift: farther)", &["ArrowRight"], &[], Gesture("Pan with nothing selected (Shift: farther)"),
"view.pan; nudges move the selection when there is one"),
@@ -416,7 +417,7 @@ fn counts_match_the_docs() {
let reserved = documented.iter().filter(|r| !r.reserved.is_empty()).count();
let pinned = ROWS.len() - documented.len();
// docs/ui-parity.md quotes these.
assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (136, 135, 1, 0, 2, 57));
assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (137, 136, 1, 0, 2, 57));
}
#[test]
+117
View File
@@ -160,6 +160,7 @@ fn what_a_style_cannot_bind_is_refused() {
("fills/3/color", "bg", "no field"),
("effects/0/radius", "blur", "no field"),
("fontSize", "size", "no field"),
("fontWeight", "bg", "no field"),
("opacity", "size", "no field"),
("fill", "nope", "no such variable"),
] {
@@ -209,6 +210,7 @@ fn style_fields_offer_their_properties_scopes() {
(&body, "letterSpacing", Some(&["LETTER_SPACING"][..])),
(&body, "fontFamily", Some(&["FONT_FAMILY"][..])),
(&body, "fontStyle", Some(&["FONT_STYLE"][..])),
(&body, "fontWeight", Some(&["FONT_WEIGHT"][..])),
// Grid fields take any number variable, as on layers; fields a style lacks take none.
(&grid, "layoutGrids/0/size", None),
(&sid, "fills/3/color", None),
@@ -244,3 +246,118 @@ fn rest_and_fig_carry_style_bindings() {
assert!(got.bound.is_empty() && got.styles.fill.as_deref() == Some(st.id.as_str()), "{got:?}");
}
}
/// A text style "Body" (made from a layer, or from `value`) used by a layer in the Light frame
/// and one in the Dark one; returns (light layer, dark layer, style id).
fn body_style(s: &mut Session, value: Option<Value>) -> (NodeId, NodeId, String) {
let (_, dark, a, _, _) = two_modes(s);
let light = node(s, a).parent.unwrap();
let text = |s: &mut Session, f: NodeId| id(run(s, "text.create", json!({"text": "Hello", "x": 0, "y": 60, "parent": f})));
let (t1, t2) = (text(s, light), text(s, dark));
let from = match &value {
Some(v) => json!({"type": "text", "name": "Body", "value": v}),
None => json!({"type": "text", "name": "Body", "from": t1}),
};
let body = run(s, "style.create", from)["id"].as_str().unwrap().to_string();
run(s, "style.apply", json!({"ids": [t1, t2], "style": body}));
(t1, t2, body)
}
fn text_of(s: &Session, n: NodeId) -> figcraft_doc::TextStyle {
node(s, n).kind.text().unwrap().style.clone()
}
#[test]
fn a_number_weight_picks_the_face_and_the_wght_axis() {
let mut s = Session::new();
let (t1, t2, body) = body_style(&mut s, None);
run(&mut s, "variable.create", json!({"collection": "Theme", "name": "weight", "type": "number", "values": {"Light": 700, "Dark": 400}}));
run(&mut s, "style.bind", json!({"style": body, "field": "fontWeight", "variable": "weight"}));
let face = |s: &Session, n: NodeId| {
let t = text_of(s, n);
(t.font_style, t.variations.get("wght").copied())
};
assert_eq!(face(&s, t1), ("Bold".to_string(), Some(700.0)));
assert_eq!(face(&s, t2), ("Regular".to_string(), Some(400.0)));
run(&mut s, "variable.set", json!({"variable": "weight", "mode": "Dark", "value": 650}));
assert_eq!(face(&s, t2), ("Bold".to_string(), Some(650.0)));
let e = s.execute("style.bind", &json!({"style": body, "field": "fontWeight", "variable": "bg"})).unwrap_err().to_string();
assert!(e.contains("takes a number variable"), "{e}");
// A style name bound afterwards replaces the number binding.
run(&mut s, "variable.createCollection", json!({"name": "Names", "modes": ["Only"]}));
run(&mut s, "variable.create", json!({"collection": "Names", "name": "face", "type": "string", "values": {"Only": "Light"}}));
run(&mut s, "style.bind", json!({"style": body, "field": "fontStyle", "variable": "face"}));
let bound = s.doc().unwrap().style(&body).unwrap().bound.clone();
assert!(bound.contains_key("fontStyle") && !bound.contains_key("fontWeight"), "{bound:?}");
// The name replaces the number's weight: no stale `wght` axis keeps drawing 700.
assert_eq!(face(&s, t1), ("Light".to_string(), None));
assert_eq!(text_of(&s, t1).weight(), 300);
}
#[test]
fn junk_weight_values_clamp_to_the_lightest_and_heaviest_face() {
let mut s = Session::new();
let (t1, _, body) = body_style(&mut s, None);
run(&mut s, "variable.create", json!({"collection": "Theme", "name": "weight", "type": "number", "values": {"Light": -50, "Dark": 5000}}));
run(&mut s, "style.bind", json!({"style": body, "field": "fontWeight", "variable": "weight"}));
let face = |s: &Session| {
let t = text_of(s, t1);
(t.font_style, t.variations.get("wght").copied())
};
assert_eq!(face(&s), ("Thin".to_string(), Some(1.0)));
run(&mut s, "variable.set", json!({"variable": "weight", "mode": "Light", "value": 5000}));
assert_eq!(face(&s), ("Black".to_string(), Some(1000.0)));
}
#[test]
fn font_weight_wins_over_font_style_when_a_file_carries_both() {
let mut s = Session::new();
let (t1, _, body) = body_style(&mut s, None);
run(&mut s, "variable.create", json!({"collection": "Theme", "name": "weight", "type": "number", "values": {"Light": 700, "Dark": 400}}));
run(&mut s, "variable.createCollection", json!({"name": "Names", "modes": ["Only"]}));
run(&mut s, "variable.create", json!({"collection": "Names", "name": "face", "type": "string", "values": {"Only": "Light"}}));
let doc = s.doc_mut().unwrap();
let (w, f) = (doc.variables.get("weight").unwrap().id.clone(), doc.variables.get("face").unwrap().id.clone());
let st = doc.styles.iter_mut().find(|x| x.id == body).unwrap();
st.bound.insert("fontStyle".into(), f);
st.bound.insert("fontWeight".into(), w);
crate::style_vars::after_load(doc);
assert_eq!(text_of(&s, t1).font_style, "Bold");
}
#[test]
fn a_number_bound_to_a_percent_line_height_applies_as_pixels() {
let mut s = Session::new();
let (t1, _, body) = body_style(&mut s, Some(json!({"fontSize": 24, "lineHeight": "150%"})));
run(&mut s, "variable.create", json!({"collection": "Theme", "name": "lh", "type": "number", "values": {"Light": 150, "Dark": 36}}));
run(&mut s, "style.bind", json!({"style": body, "field": "lineHeight", "variable": "lh"}));
let lh = |s: &Session| match &s.doc().unwrap().style(&body).unwrap().kind {
StyleKind::Text { style, .. } => style.line_height,
k => panic!("{k:?}"),
};
assert_eq!(lh(&s), figcraft_doc::LineHeight::Pixels(150.0));
assert_eq!(text_of(&s, t1).line_height, figcraft_doc::LineHeight::Pixels(150.0));
assert_eq!(node(&s, t1).height, 150.0);
run(&mut s, "variable.set", json!({"variable": "lh", "mode": "Light", "value": 36}));
assert_eq!((lh(&s), node(&s, t1).height), (figcraft_doc::LineHeight::Pixels(36.0), 36.0));
}
#[test]
fn a_text_style_binding_round_trips_through_fig() {
let mut s = Session::new();
let (_, _, body) = body_style(&mut s, None);
run(&mut s, "variable.create", json!({"collection": "Theme", "name": "weight", "type": "number", "values": {"Light": 700, "Dark": 400}}));
for (field, var) in [("fontSize", "size"), ("lineHeight", "size"), ("fontWeight", "weight")] {
run(&mut s, "style.bind", json!({"style": body, "field": field, "variable": var}));
}
let doc = s.doc().unwrap().clone();
let (bytes, warnings) = figcraft_format::fig::export(&doc, None).unwrap();
let (mut back, _) = figcraft_format::fig::import(&bytes, "x").unwrap();
crate::style_vars::after_load(&mut back);
let st = back.styles.iter().find(|x| x.name == "Body").unwrap();
assert!(st.bound.contains_key("fontSize") && st.bound.contains_key("lineHeight"), "{st:?}");
// The format has no weight field yet: the settled name comes back, not the binding (the file has no `wght` axis field yet).
assert!(warnings.iter().any(|w| w.contains("no field for")), "{warnings:?}");
let StyleKind::Text { style, .. } = &st.kind else { panic!("{st:?}") };
assert_eq!(style.font_style, "Bold");
}
+13
View File
@@ -562,6 +562,19 @@ fn css_export_gives_lengths_a_unit_and_keeps_opacity_bare() {
assert!(css.contains("--card: 12px;") && css.contains("--fade: 0.5;") && css.contains("--half: 0.5;"), "{css}");
}
#[test]
fn css_export_keeps_a_style_weight_bare() {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "Brand"}));
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "weight", "type": "number", "value": 700}));
let t = idv(&ex(&mut s, "text.create", json!({"text": "Hi", "x": 0, "y": 0})));
let st = ex(&mut s, "style.create", json!({"type": "text", "name": "Body", "from": t}))["id"].as_str().unwrap_or_default().to_string();
ex(&mut s, "style.bind", json!({"style": st, "field": "fontWeight", "variable": "weight"}));
let css = ex(&mut s, "variable.export", json!({"format": "css"}))["text"].as_str().unwrap_or_default().to_string();
// A weight bound only to a text style is still a bare number, not a length.
assert!(css.contains("--weight: 700;") && !css.contains("700px"), "{css}");
}
#[test]
fn review_a_long_alias_chain_keeps_its_role_to_the_end() {
let mut s = Session::new();
+1 -1
View File
@@ -148,7 +148,7 @@ fn import_with_image_limit(bytes: &[u8], name: &str, image_limit: u64) -> Result
let max_nodes = layer_budget(packed);
drop(message);
let file_name = archive.file_name.as_deref().filter(|n| !n.trim().is_empty()).unwrap_or(name);
let (mut json, mut warnings, networks) = map::to_rest(&msg, file_name, max_nodes);
let (mut json, mut warnings, networks) = map::to_rest_for(&msg, file_name, max_nodes, board);
// The message goes before the layers are built, so the images it carries are copied out first.
let embedded = embedded_copies(&msg);
drop(msg);
+97 -1
View File
@@ -446,10 +446,106 @@ fn a_jam_file_imports_like_a_fig() {
let (doc, warnings) = import(&bytes, "Synthetic board").unwrap();
assert_eq!(doc.kind, figcraft_doc::FileKind::Board, "a FigJam whiteboard opens as a board");
let note = named(&doc, "Board object");
assert!(matches!(note.kind, NodeKind::Frame) && note.children.len() == 3, "the sticky with its background, note and author");
assert!(matches!(note.kind, NodeKind::Frame) && note.children.len() == 2, "the sticky is native: its note and author");
assert_eq!(note.board, Some(figcraft_doc::BoardItem::Sticky { author: "Test Author".into() }));
assert!(matches!(named(&doc, "Synthetic table").kind, NodeKind::Frame));
assert!(doc.nodes().all(|n| n.name != "Unknown object"), "left out");
assert!(warnings.iter().any(|w| w.contains("table layers import as")), "{warnings:?}");
assert!(warnings.iter().any(|w| w.contains("SYNTHETIC_JAM_ONLY")), "a note says what was left out: {warnings:?}");
}
}
/// Import `layer` through the public importer as a FigJam whiteboard (`fig-jam.` header).
fn jam(layer: Kv<'static>) -> (Document, Vec<String>) {
let mut bytes = canvas(&board_schema(), &message(layer));
bytes.splice(..8, *b"fig-jam.");
import(&bytes, "Synthetic board").unwrap()
}
/// On a whiteboard a sticky is a native sticky: its colour, size, text and author come along.
#[test]
fn a_jam_sticky_is_a_native_sticky_with_its_colour_text_and_author() {
let (doc, warnings) = jam(board_node("STICKY"));
assert_eq!(doc.kind, figcraft_doc::FileKind::Board);
let frame = named(&doc, "Board object");
assert_eq!(frame.board, Some(figcraft_doc::BoardItem::Sticky { author: "Test Author".into() }));
assert_eq!(frame.fills[0].solid_color().unwrap().to_rgba8(), [255, 191, 64, 255]);
assert_eq!((frame.width, frame.height), (240.0, 240.0));
assert_eq!(frame.opacity, 0.5);
let layout = frame.layout.as_ref().expect("sticky auto layout");
assert_eq!(layout.padding, [20.0; 4]);
assert_eq!(frame.child_layout.min_h, Some(240.0));
assert_eq!(frame.children.len(), 2, "the note and its author, no background vector");
let texts: Vec<_> = frame.children.iter().map(|id| doc.node(*id).unwrap()).collect();
assert!(texts.iter().all(|n| n.kind.text().is_some()));
let note = texts[0].kind.text().unwrap();
assert_eq!(note.characters, "Olá\nIdea");
assert_eq!(note.style.font_size, 24.0);
assert!(note.runs.iter().any(|r| r.style.font_size == 18.0), "inline styles survive");
let author = texts[1].kind.text().unwrap();
assert_eq!(author.characters, "Test Author");
assert_eq!(author.style.font_size, 12.0);
assert!(!warnings.iter().any(|w| w.contains("skipped")), "{warnings:?}");
}
/// A sticky whose author line is not saved has an empty author and just its note.
#[test]
fn a_jam_sticky_without_an_author_line_has_no_author() {
let mut layer = board_node("STICKY");
let background = record(0, vec![("fillPaints", arr(vec![solid(1.0, 0.75, 0.25)]))]);
set(&mut layer, "nodeGenerationData", o(vec![("overrides", arr(vec![background, record(1, text("Olá\nIdea", 24.0))]))]));
let (doc, _) = jam(layer);
let frame = named(&doc, "Board object");
assert_eq!(frame.children.len(), 1);
let Some(figcraft_doc::BoardItem::Sticky { author }) = &frame.board else { panic!("{:?}", frame.board) };
assert!(author.is_empty());
}
/// A sticky record that isn't complete keeps today's generated layers and no board item.
#[test]
fn a_damaged_jam_sticky_keeps_the_generated_layers() {
let gradient = o(vec![("type", e("GRADIENT_LINEAR")), ("visible", Kv::Bool(true))]);
let cases: Vec<Kv<'static>> = {
let mut v = Vec::new();
// Layout version 2.
let mut a = board_node("STICKY");
set(&mut a, "derivedImmutableFrameData", o(vec![("version", u(2))]));
v.push(a);
// Background record missing.
let mut b = board_node("STICKY");
set(&mut b, "nodeGenerationData", o(vec![("overrides", arr(vec![record(1, text("Olá", 24.0))]))]));
v.push(b);
// A stroke on the background.
let mut c = board_node("STICKY");
let stroked = record(0, vec![("fillPaints", arr(vec![solid(1.0, 0.75, 0.25)])), ("strokePaints", arr(vec![solid(0.0, 0.0, 0.0)]))]);
set(&mut c, "nodeGenerationData", o(vec![("overrides", arr(vec![stroked, record(1, text("Olá", 24.0))]))]));
v.push(c);
// A gradient on the background.
let mut d = board_node("STICKY");
let graded = record(0, vec![("fillPaints", arr(vec![gradient]))]);
set(&mut d, "nodeGenerationData", o(vec![("overrides", arr(vec![graded, record(1, text("Olá", 24.0))]))]));
v.push(d);
// Blank note.
let mut f = board_node("STICKY");
let background = record(0, vec![("fillPaints", arr(vec![solid(1.0, 0.75, 0.25)]))]);
set(&mut f, "nodeGenerationData", o(vec![("overrides", arr(vec![background, record(1, text(" ", 24.0))]))]));
v.push(f);
v
};
for (i, layer) in cases.into_iter().enumerate() {
let (as_fig, _) = imported(layer.clone());
let (doc, _) = jam(layer);
let frame = named(&doc, "Board object");
assert!(frame.board.is_none(), "case {i}");
assert_eq!(frame.children.len(), named(&as_fig, "Board object").children.len(), "case {i}: the generated layers, as in a .fig");
}
}
/// The same sticky in a `.fig` stays the design frame it always was.
#[test]
fn a_fig_sticky_stays_a_design_frame() {
let (doc, _) = imported(board_node("STICKY"));
let frame = named(&doc, "Board object");
assert!(frame.board.is_none());
assert_eq!(frame.children.len(), 3);
}
+12
View File
@@ -17,6 +17,7 @@ use serde_json::{Map, Value, json};
use super::kiwi::Kv;
use super::network;
mod board;
mod component;
mod connector;
mod document;
@@ -50,8 +51,16 @@ const MAX_ASSET_PART: usize = 256;
/// The REST API file JSON for message `msg`, with the warnings about what was skipped and the
/// vector networks its layers name by `vectorNetworkRef` (for [`crate::rest::import_with_networks`]).
#[cfg(test)]
pub(super) fn to_rest(msg: &Kv, name: &str, max_nodes: usize) -> (Value, Vec<String>, Vec<Option<VectorNetwork>>) {
to_rest_for(msg, name, max_nodes, false)
}
/// The REST JSON (as `to_rest`) for a FigJam whiteboard (`board`) or a design file: a
/// whiteboard's stickies come out as native board items.
pub(super) fn to_rest_for(msg: &Kv, name: &str, max_nodes: usize, board: bool) -> (Value, Vec<String>, Vec<Option<VectorNetwork>>) {
let mut m = Mapper::new(msg, max_nodes);
m.board = board;
let _canon = VarCanon::install(&m);
let doc = m.document();
// Large values are moved in, never passed through `json!`, which serialises its arguments:
@@ -362,6 +371,8 @@ struct Mapper<'a> {
/// Region paint elements (paints and gradient stops) read so far: one budget for the whole
/// file, since layers with region paints don't share their network.
region_paint_elements: usize,
/// The file is a FigJam whiteboard: its objects are built as native board items.
board: bool,
}
impl<'a> Mapper<'a> {
@@ -439,6 +450,7 @@ impl<'a> Mapper<'a> {
max_nodes,
reserved: 0,
networks: Vec::new(),
board: false,
network_ids: HashMap::new(),
region_paint_elements: 0,
}
+88
View File
@@ -0,0 +1,88 @@
//! FigJam whiteboard objects as native board items: what a `.jam` import builds in place of the
//! generated frames a `.fig` keeps. The REST JSON carries them as a `board` field
//! ([`crate::rest`] reads it).
use super::layer::{own_text, valid_size};
use super::paint::paint;
use super::{Affine, Kv, MAX_DEPTH, Mapper, Obj, Value, View, generated_record, json, num};
impl<'a> Mapper<'a> {
/// A sticky as a native board sticky: its frame (size, place, opacity and shadows as saved,
/// the saved colour) holding the note and, when saved, the author line. `None` (nothing
/// changed) unless every generated record is complete: then the caller keeps the generated
/// layers.
pub(super) fn native_sticky(&mut self, o: &mut Obj, v: &View<'a>, rest_id: &str, depth: usize) -> Option<Vec<Value>> {
if v.get("nodeGenerationData").is_none() || v.get("derivedImmutableFrameData").and_then(|d| num(d, "version")) != Some(1.0) {
return None;
}
let record = |id: u32| {
let (style, bounds) = (generated_record(v, "nodeGenerationData", id)?, generated_record(v, "derivedImmutableFrameData", id)?);
let view = View::of(style).with([(bounds, 1.0)]);
(valid_size(view.get("size")).is_some() && view.transform().is_some()).then_some(view)
};
let background = record(0)?;
let fills = background.list("fillPaints");
let solid = matches!(fills, [p] if p.get("type").and_then(Kv::str) == Some("SOLID") && p.get("visible").and_then(Kv::bool) != Some(false));
if !solid || !background.list("strokePaints").is_empty() {
return None;
}
let note = record(1).filter(|n| own_text(n).is_some())?;
let author = record(2).filter(|a| a.flag("visible") != Some(false) && own_text(a).is_some());
let wanted = 1 + usize::from(author.is_some());
if depth >= MAX_DEPTH || self.count.saturating_add(wanted) > self.budget() {
return None;
}
let (w, h) = v.size().or_else(|| o.get("size").and_then(|s| Some((s.get("x")?.as_f64()?, s.get("y")?.as_f64()?))))?;
let at = note.transform().unwrap_or_default().as_coeffs();
let pad = |x: f64| if x.is_finite() { x.clamp(0.0, (w.min(h) / 2.0).max(0.0)) } else { 0.0 };
let (pad_x, pad_y) = (pad(at[4]), pad(at[5]));
// Everything is valid: build.
let paints: Vec<Value> = fills.iter().filter_map(|p| paint(p, &mut |m| self.warn(m))).collect();
o.insert("fills".into(), Value::Array(paints));
o.insert("strokes".into(), json!([]));
o.insert("clipsContent".into(), json!(false));
match background.len("cornerRadius") {
Some(r) => o.insert("cornerRadius".into(), json!(r)),
None => o.remove("cornerRadius"),
};
for (k, val) in [
("layoutMode", json!("VERTICAL")),
("primaryAxisAlignItems", json!("SPACE_BETWEEN")),
("counterAxisAlignItems", json!("MIN")),
("primaryAxisSizingMode", json!("AUTO")),
("counterAxisSizingMode", json!("FIXED")),
("minHeight", json!(h)),
("itemSpacing", json!(8.0)),
("paddingTop", json!(pad_y)),
("paddingBottom", json!(pad_y)),
("paddingLeft", json!(pad_x)),
("paddingRight", json!(pad_x)),
] {
o.insert(k.into(), val);
}
let mut kids = Vec::new();
let mut written = String::new();
for (local, view) in [(1, Some(note)), (2, author)] {
let Some(view) = view else { continue };
let chars = own_text(&view).unwrap_or_default();
let mut layer = Obj::new();
layer.insert("id".into(), json!(format!("{rest_id};generated{local}")));
layer.insert("type".into(), json!("TEXT"));
layer.insert("name".into(), json!(if local == 1 { chars.chars().take(40).collect::<String>() } else { "Author".to_string() }));
self.common(&mut layer, &view, "TEXT", Affine::IDENTITY, None);
self.text(&mut layer, &view);
if let Some(Value::Object(style)) = layer.get_mut("style") {
style.insert("textAutoResize".into(), json!("HEIGHT"));
style.insert("textAlignHorizontal".into(), json!("LEFT"));
}
layer.insert("layoutSizingHorizontal".into(), json!("FILL"));
if local == 2 {
written = chars.trim().to_string();
}
self.count += 1;
kids.push(Value::Object(layer));
}
o.insert("board".into(), json!({"type": "sticky", "author": written}));
Some(kids)
}
}
+10 -2
View File
@@ -174,7 +174,9 @@ impl<'a> Mapper<'a> {
// A label without a snapshot: the connector becomes a frame holding its line and its label.
let connector_label = own == "CONNECTOR" && !connector_snapshot && self.connector_label_fits(&view, depth);
let ty = if connector_label { "FRAME" } else { Self::mapped_type(own, &view, connector_snapshot, connector_terminal, stand)? };
if let Some((_, what)) = stand.filter(|_| own != "TEXT_PATH") {
// A whiteboard's sticky says it imports as a frame only when it can't be a native one.
let native_sticky = self.board && own == "STICKY";
if let Some((_, what)) = stand.filter(|_| own != "TEXT_PATH" && !native_sticky) {
self.warn(format!("{} layers import as {what}", own.to_ascii_lowercase().replace('_', " ")));
}
if connector_snapshot {
@@ -262,12 +264,18 @@ impl<'a> Mapper<'a> {
});
kids.splice(0..0, connector_kids);
if stand.is_some_and(|s| s.0 == "FRAME") {
if let Some(snapshot) = self.board_snapshot(&mut o, &view, own, &rest_id, depth) {
let native = if native_sticky { self.native_sticky(&mut o, &view, &rest_id, depth) } else { None };
if let Some(native) = native {
kids.splice(0..0, native);
} else if let Some(snapshot) = self.board_snapshot(&mut o, &view, own, &rest_id, depth) {
kids.splice(0..0, snapshot);
} else {
kids.splice(0..0, self.shape_with_text(&mut o, &view, own, &rest_id));
kids.extend(self.inner_text(&o, &view, &rest_id, own == "STICKY"));
}
if native_sticky && !o.contains_key("board") {
self.warn("sticky layers import as frames with their text");
}
}
if !kids.is_empty() {
o.insert("children".into(), Value::Array(kids));
+2 -1
View File
@@ -1583,7 +1583,8 @@ fn dump_samples() {
std::fs::write(format!("{out}/{stem}.schema.txt"), txt).unwrap();
let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
std::fs::write(format!("{out}/{stem}.json"), serde_json::to_string_pretty(&debug_json(&msg)).unwrap()).unwrap();
let (rest, ..) = map::to_rest(&msg, &stem, super::layer_budget(message.len()));
// A whiteboard's dump shows its native board items as the import makes them.
let (rest, ..) = map::to_rest_for(&msg, &stem, super::layer_budget(message.len()), a.canvas.starts_with(super::JAM_HEADER));
std::fs::write(format!("{out}/{stem}.rest.json"), serde_json::to_string_pretty(&rest).unwrap()).unwrap();
}
}
+37
View File
@@ -442,6 +442,8 @@ pub(crate) struct Ctx<'a> {
set_defs: BTreeMap<NodeId, BTreeMap<String, PropDef>>,
/// Prototype starting points: (page, name, REST node id).
flows: Vec<(NodeId, String, String)>,
/// Board connectors and labels, resolved once every layer is imported (their ends name layers).
pending_boards: Vec<(NodeId, &'a Value)>,
/// Layers with prototype interactions, resolved once every layer is imported.
protos: Vec<(NodeId, &'a Value)>,
/// Layers that carry overlay settings (position, background, outside clicks), by REST id.
@@ -478,6 +480,7 @@ impl<'a> Ctx<'a> {
swap_defaults: Vec::new(),
set_defs: BTreeMap::new(),
flows: Vec::new(),
pending_boards: Vec::new(),
protos: Vec::new(),
overlay_meta: BTreeMap::new(),
var_kinds: BTreeMap::new(),
@@ -781,6 +784,9 @@ impl<'a> Ctx<'a> {
self.overlay_meta.entry(rid).or_insert(j);
}
self.bindings(n, j);
if let Some(b) = j.get("board") {
self.board_item(n, b);
}
let restored = j.get("outlinedStroke").is_some_and(|original| restore_stroke(n, original));
// Styles and bindings were read against the stroke: they follow its paints to the fill.
if outlined && !restored {
@@ -788,6 +794,36 @@ impl<'a> Ctx<'a> {
}
}
/// The `board` field (a FigCraft extension): what the object is on a board. A sticky or shape
/// is set at once; anything else, or junk, is ignored with a warning.
fn board_item(&mut self, n: &mut Node, b: &'a Value) {
// A sticky or shape is a frame holding its layers: on anything else it is ignored.
let frame = n.kind.is_frame_like();
let item = match text(b, "type") {
Some("sticky") if frame => {
let author = text(b, "author").unwrap_or("").trim();
Some(figcraft_doc::BoardItem::Sticky { author: author.chars().filter(|c| !c.is_control()).take(64).collect() })
}
Some("shape") if frame => {
text(b, "shape").and_then(figcraft_doc::BoardShape::from_id).map(|shape| figcraft_doc::BoardItem::Shape { shape })
}
_ => None,
};
match item {
Some(item) => n.board = Some(item),
None => self.warn(format!("unknown board item on {} was ignored", n.name)),
}
}
/// Board items that name other layers (connector ends, a label's connector), once every
/// layer is imported and `ids` can resolve them.
fn resolve_boards(&mut self) {
for (id, _) in std::mem::take(&mut self.pending_boards) {
let name = self.doc.node(id).map(|n| n.name.clone()).unwrap_or_default();
self.warn(format!("unknown board item on {name} was ignored"));
}
}
/// A vector's network: the editable one when the JSON carries it (`vectorNetwork`, or
/// `vectorNetworkRef` naming one built by the `.fig` import: our extensions), else from its
/// geometry. A stroke-only vector uses the stroke outline, painted with the stroke paints
@@ -1096,6 +1132,7 @@ impl<'a> Ctx<'a> {
}
}
self.resolve_instances();
self.resolve_boards();
if let Some(sources) = self.fig_sources.take() {
let missing = sources.apply(&mut self.doc, &self.ids);
if missing > 0 {
@@ -93,3 +93,29 @@ fn thousands_of_distinct_style_references_import_in_linear_time() {
assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed());
assert_eq!(doc.styles.len(), n);
}
#[test]
fn a_board_field_becomes_the_nodes_board_item() {
let frame = json!({"id": "1:1", "name": "Note", "type": "FRAME", "relativeTransform": at(0.0, 0.0), "size": {"x": 200, "y": 200},
"board": {"type": "sticky", "author": "A\u{7}"}});
let (doc, warnings) = import(&file(json!([frame])), &no_images).unwrap();
assert_eq!(named(&doc, "Note").board, Some(figcraft_doc::BoardItem::Sticky { author: "A".into() }));
assert!(!warnings.iter().any(|w| w.contains("board item")), "{warnings:?}");
}
#[test]
fn junk_board_fields_are_ignored_with_a_warning() {
for junk in [json!(5), json!({"type": "nope"}), json!({"type": "shape", "shape": "blob"}), json!({"type": "connector"})] {
let frame =
json!({"id": "1:1", "name": "Junk", "type": "FRAME", "relativeTransform": at(0.0, 0.0), "size": {"x": 20, "y": 20}, "board": junk});
let (doc, warnings) = import(&file(json!([frame])), &no_images).unwrap();
assert!(named(&doc, "Junk").board.is_none(), "{junk}");
assert!(warnings.iter().any(|w| w.contains("unknown board item on Junk was ignored")), "{junk}: {warnings:?}");
}
// A sticky is a frame holding its layers: on a rectangle the item is ignored.
let rect = json!({"id": "1:2", "name": "Rect", "type": "RECTANGLE", "relativeTransform": at(0.0, 0.0), "size": {"x": 20, "y": 20},
"board": {"type": "sticky", "author": "A"}});
let (doc, warnings) = import(&file(json!([rect])), &no_images).unwrap();
assert!(named(&doc, "Rect").board.is_none());
assert!(warnings.iter().any(|w| w.contains("unknown board item on Rect was ignored")), "{warnings:?}");
}
+1 -1
View File
@@ -336,7 +336,7 @@ fn tools() -> Vec<Value> {
"y": num("Page y"),
"to": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2, "description": "Drag end [x, y] (page)"},
"steps": int("Intermediate move events for drag (default 8)"),
"tool": {"type": "string", "description": "Tool to choose first: move|scale|hand|frame|section|slice|rectangle|line|arrow|ellipse|polygon|star|pen|pencil|text|comment"},
"tool": {"type": "string", "description": "Tool to choose first: move|scale|hand|frame|section|slice|rectangle|line|arrow|ellipse|polygon|star|pen|pencil|text|comment|annotation"},
"shift": boolean("Shift held"),
"alt": boolean("Alt/Option held"),
"cmd": boolean("Cmd (macOS) / Ctrl held"),
+1 -1
View File
@@ -33,7 +33,7 @@ const MAX_CACHED_IMAGES: usize = 256;
/// The largest sides of the copies the canvas draws larger images from while they show smaller
/// (device pixels): two 16 000 × 10 000 images take 640 MB each in full, past the cache's budget
/// together, and 42 MB each at 4096 (168 MB at 8192).
pub const DISPLAY_SIDES: [u32; 2] = [4096, 8192];
pub const DISPLAY_SIDES: [u32; 6] = [256, 512, 1024, 2048, 4096, 8192];
/// Decoded pixmaps by image, filters and profile, holding at most a byte budget of pixels: adding
/// one past the budget (or past [`MAX_CACHED_IMAGES`]) drops the least recently used first, so
+7 -6
View File
@@ -8,7 +8,7 @@ use figcraft_color::{ImageScaleMode, Paint};
use figcraft_doc::{Document, ImageData, NodeId, NodeKind};
use tiny_skia::Pixmap;
use crate::images::{self, DISPLAY_SIDES, MAX_CACHED_BYTES};
use crate::images::{self, MAX_CACHED_BYTES};
use crate::{Options, View, render_view};
const W: u32 = 6000;
@@ -63,12 +63,11 @@ fn sharp_stripes(p: &Pixmap, y: u32, black_first: bool, each: u32) -> bool {
#[test]
fn zoomed_out_the_canvas_draws_both_images_from_copies_that_stay_decoded() {
let (d, page, keys) = doc("zoomed-out");
// At 25% each shows 1500 px long: drawn from 4096 px copies, not decoded in full.
render_view(&d, page, &view(0.25), &canvas()).unwrap();
let first = keys.each_ref().map(|k| shown(&d, k, 1500.0));
for (k, p) in keys.iter().zip(&first) {
assert_eq!((p.width(), p.height()), (DISPLAY_SIDES[0], 5), "{k}");
assert_eq!(images::cached_sides(k), vec![DISPLAY_SIDES[0]], "{k}: only its copy is decoded");
assert_eq!((p.width(), p.height()), (2048, 3), "{k}");
assert_eq!(images::cached_sides(k), vec![2048], "{k}: only its copy is decoded");
}
// Drawn again (a drag, a pan), both are still the copies decoded the first time.
render_view(&d, page, &view(0.25), &canvas()).unwrap();
@@ -93,13 +92,15 @@ fn exports_and_close_ups_draw_from_the_image_itself() {
#[test]
fn the_copy_is_the_smallest_that_holds_what_shows() {
let side = |long, shown| images::display_side(long, shown);
assert_eq!(side(6000, 1500.0), 4096);
assert_eq!(side(6000, 1500.0), 2048);
assert_eq!(side(16_000, 4096.0), 4096);
assert_eq!(side(16_000, 4097.0), 8192);
assert_eq!(side(4096, 100.0), 256);
assert_eq!(side(3000, 20.0), 256);
// Shown at more than the largest copy, or an image no larger than the copy: the image itself.
assert_eq!(side(16_000, 9000.0), 16_384);
assert_eq!(side(6000, 5000.0), 16_384);
assert_eq!(side(4096, 100.0), 16_384);
assert_eq!(side(200, 100.0), 16_384);
// An unknown size or a meaningless scale: the image itself.
assert_eq!(side(0, 100.0), 16_384);
assert_eq!(side(16_000, f64::NAN), 16_384);
+39 -7
View File
@@ -266,11 +266,11 @@ fn render_one(doc: &Document, page: NodeId, view: &View, opts: &Options) -> Opti
/// Render several nodes (in z-order) into an existing pixmap through `view`.
pub fn render_nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap) {
nodes_into(doc, ids, view, pix, 0.0);
nodes_into(doc, ids, view, pix, 0.0, None);
}
/// [`render_nodes_into`] `time` seconds into animated GIFs.
fn nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap, time: f64) {
fn nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap, time: f64, under: Option<&backdrop::Under>) {
let bounds = draw::Bounds::default();
let ctx = draw::Ctx {
doc,
@@ -282,7 +282,7 @@ fn nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap, tim
lod: false,
preview: false,
magnified_image_pixels: false,
under: None,
under,
time,
bounds: &bounds,
};
@@ -353,16 +353,24 @@ pub fn render_node(doc: &Document, id: NodeId, scale: f64) -> Option<Pixmap> {
/// [`render_node`] `time` seconds into animated GIFs (the presenter plays them).
pub fn render_node_at(doc: &Document, id: NodeId, scale: f64, time: f64) -> Option<Pixmap> {
area_at(doc, &[id], export_bounds(doc, id)?, scale, time)
area_at(doc, &[id], None, export_bounds(doc, id)?, scale, time)
}
/// Rasterise `ids` (bottom to top) over page area `b` at `scale` (export): the image starts at the
/// area's top-left and its sides round up to whole pixels. `None` past 16384 pixels a side.
pub fn render_area(doc: &Document, ids: &[NodeId], b: Rect, scale: f64) -> Option<Pixmap> {
area_at(doc, ids, b, scale, 0.0)
area_at(doc, ids, None, b, scale, 0.0)
}
fn area_at(doc: &Document, ids: &[NodeId], b: Rect, scale: f64, time: f64) -> Option<Pixmap> {
/// [`render_area`] for an export of layers that sit among `page_layers` (the page's top-level
/// layers, in z-order): a background blur or glass in `ids` reads what lies under the first of
/// them on that page, clipped to the area (#2213). Only `ids` are drawn; the layers above stay
/// out. Without such an effect it is [`render_area`].
pub fn render_area_over(doc: &Document, ids: &[NodeId], page_layers: &[NodeId], b: Rect, scale: f64) -> Option<Pixmap> {
area_at(doc, ids, Some(page_layers), b, scale, 0.0)
}
fn area_at(doc: &Document, ids: &[NodeId], page_layers: Option<&[NodeId]>, b: Rect, scale: f64, time: f64) -> Option<Pixmap> {
let scale = if scale.is_finite() && scale > 0.0 { scale.min(64.0) } else { 1.0 };
let w = (b.width() * scale - 1e-6).ceil().max(1.0);
let h = (b.height() * scale - 1e-6).ceil().max(1.0);
@@ -371,7 +379,31 @@ fn area_at(doc: &Document, ids: &[NodeId], b: Rect, scale: f64, time: f64) -> Op
}
let view = View { origin: (b.x0, b.y0), scale, width: w as u32, height: h as u32 };
let mut pix = new_pixmap(view.width, view.height)?;
nodes_into(doc, ids, &view, &mut pix, time);
// What the backdrop effects read: the page under the first layer, with the margin they reach.
let reach = page_layers.and_then(|_| {
ids.iter()
.flat_map(|i| doc.subtree(*i))
.filter_map(|i| doc.node(i))
.filter(|n| n.visible && backdrop::has_effects(n))
.map(|n| backdrop::pad(n, scale))
.max()
});
let beneath = match (page_layers, ids.first(), reach) {
(Some(layers), Some(first), Some(m)) => {
let m = i32::try_from(m).unwrap_or(0);
let p = f64::from(m) / scale;
let around = View {
origin: (b.x0 - p, b.y0 - p),
scale,
width: view.width.saturating_add(m.unsigned_abs() * 2),
height: view.height.saturating_add(m.unsigned_abs() * 2),
};
backdrop::render_beneath(doc, layers, *first, &around).map(|back| (back, m))
}
_ => None,
};
let under = beneath.as_ref().map(|(back, m)| backdrop::Under::new(back, (*m, *m), None));
nodes_into(doc, ids, &view, &mut pix, time, under.as_ref());
Some(pix)
}
+2
View File
@@ -122,6 +122,7 @@ name = "English"
"Android Vector Drawable" = "Android Vector Drawable"
"Angular" = "Angular"
"Animation" = "Animation"
"Annotation" = "Annotation"
"Annotations" = "Annotations"
"Annotations shown on the canvas" = "Annotations shown on the canvas"
"Another server, or one that needs its key. The usual server is set in Preferences." = "Another server, or one that needs its key. The usual server is set in Preferences."
@@ -1885,6 +1886,7 @@ name = "English"
"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "some vector segments refer to style settings missing from the file; they use the layer's stroke"
"stale or invalid grid track sizing entries were ignored" = "stale or invalid grid track sizing entries were ignored"
"stars and polygons without point counts or geometry use the default point count" = "stars and polygons without point counts or geometry use the default point count"
"sticky layers import as frames with their text" = "sticky layers import as frames with their text"
"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "style settings of some vector segments aren't supported; those segments use the layer's stroke"
"text path underline or strikethrough decoration is not rendered" = "text path underline or strikethrough decoration is not rendered"
"text style overrides refer to missing table entries" = "text style overrides refer to missing table entries"
+2
View File
@@ -22,6 +22,7 @@ status = "draft — needs native review"
"Along the top" = "A lo largo del borde superior"
"An earlier vote" = "Una votación anterior"
"Android Vector Drawable" = "Android Vector Drawable"
"Annotation" = "Anotación"
"Best effort" = "Mejor aproximación"
"Between the selected layers" = "Entre las capas seleccionadas"
"Brainstorm" = "Lluvia de ideas"
@@ -2366,6 +2367,7 @@ status = "draft — needs native review"
"text.underline" = "Subrayado"
"text.weightDown" = "Reducir peso de fuente"
"text.weightUp" = "Aumentar peso de fuente"
"tool.annotation" = "Anotación"
"tool.arrow" = "Flecha"
"tool.comment" = "Comentario"
"tool.connector" = "Conector"
+12
View File
@@ -266,6 +266,18 @@ impl FigApp {
self.ui.comments.show(id);
}
}
"annotation.new" => {
// The Annotation tool: show the Inspect tab (never toggle it away) and put the
// caret in the layer's new-note field.
// Only a selected layer's field can show; a request for anything else would wait
// and take the keyboard later.
let shown =
|id| self.session.selection().contains(&id) && self.session.doc().is_ok_and(|d| d.node(id).is_some_and(|n| !n.is_page()));
if let Some(id) = r.data.get("id").and_then(figcraft_engine::params::id_of).filter(|id| shown(*id)) {
self.ui.right_tab = RightTab::Inspect;
self.ui.dev.focus_new_note = Some(id);
}
}
"comment.new" => {
let x = r.data.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0);
let y = r.data.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0);
+44
View File
@@ -196,3 +196,47 @@ fn the_playground_swaps_an_instance_in_the_preview_only() {
let props = u.app.session.execute("component.properties", &json!({"id": inst})).unwrap();
assert_eq!(props["properties"][0]["value"], json!(heart.0), "{star:?}");
}
#[test]
fn the_annotation_tool_opens_the_inspect_tab_with_the_note_field_focused() {
let (mut u, _, r) = setup();
u.app.ui.right_tab = crate::RightTab::Design;
u.frames(2, Vec::new);
u.run("tool.annotation", json!({"id": r}));
u.frames(2, Vec::new);
assert_eq!(u.app.ui.right_tab, crate::RightTab::Inspect, "the request shows the Inspect tab");
let field = egui::Id::unique(("annot_new", r)).with("te");
assert!(u.ctx.memory(|m| m.has_focus(field)), "the layer's new-note field has the caret");
assert_eq!(u.app.ui.dev.focus_new_note, None, "the focus request is spent");
// Asking again while already on the Inspect tab does not toggle it away.
u.run("tool.annotation", json!({"id": r}));
u.frames(2, Vec::new);
assert_eq!(u.app.ui.right_tab, crate::RightTab::Inspect);
assert!(u.ctx.memory(|m| m.has_focus(field)), "the field has the caret again");
assert_eq!(u.app.ui.dev.focus_new_note, None, "and the request is spent");
}
/// A request for a layer that isn't selected (or is gone) does not wait to take the keyboard later.
#[test]
fn a_note_request_for_a_layer_that_is_not_selected_is_dropped() {
let (mut u, _, r) = setup();
u.app.session.execute("select.set", &json!({"ids": []})).unwrap();
u.frames(2, Vec::new);
u.app.session.request_ui("annotation.new", json!({"id": r}));
u.frames(2, Vec::new);
assert_eq!(u.app.ui.dev.focus_new_note, None);
}
/// Choosing the Annotation tool from the toolbar on the Inspect tab keeps the tab (an editing tool
/// would leave it for Design) and asks for the selected layer's note.
#[test]
fn the_toolbar_keeps_the_inspect_tab_for_the_annotation_tool() {
let (mut u, _, r) = setup();
u.app.session.execute("select.set", &json!({"ids": [r]})).unwrap();
crate::toolbar::choose(&mut u.app, figcraft_engine::ToolId::Annotation);
u.frames(2, Vec::new);
assert_eq!(u.app.ui.right_tab, crate::RightTab::Inspect);
assert!(u.ctx.memory(|m| m.has_focus(egui::Id::unique(("annot_new", r)).with("te"))), "the note field has the caret");
crate::toolbar::choose(&mut u.app, figcraft_engine::ToolId::Rectangle);
assert_eq!(u.app.ui.right_tab, crate::RightTab::Design, "a drawing tool leaves it");
}
@@ -0,0 +1,52 @@
//! A section header's icon buttons (Appearance, Fill, Stroke, Effects...) draw their glyphs at the
//! same size as the panel's other 24 pt icon buttons (#2227).
use egui::{Context, Rect, pos2, vec2};
use crate::icons::{self, Icon};
use crate::widgets::{icon_button_at, icon_glyph_rect, section_header};
/// Bounding box of everything painted in one frame of `add`.
fn painted_box(add: impl FnOnce(&mut egui::Ui)) -> Rect {
let ctx = Context::default();
let mut add = Some(add);
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 200.0))), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
if let Some(f) = add.take() {
f(ui);
}
});
out.textures_delta.clear();
out.shapes
.iter()
.filter(|s| matches!(s.shape, egui::Shape::Path(_) | egui::Shape::Mesh(_) | egui::Shape::LineSegment { .. } | egui::Shape::Circle(_)))
.map(|s| s.shape.visual_bounding_rect())
.fold(Rect::NOTHING, |a, b| a.union(b))
}
/// The header's eye, `icon_button_at`'s and a fill row's (`icons::button`, the reporter's
/// reference) are drawn at one size.
#[test]
fn header_eye_draws_as_large_as_a_fill_rows_eye() {
let header = painted_box(|ui| {
section_header(ui, "Appearance", &[(Icon::Eye, "Hide")], false);
});
let at = painted_box(|ui| {
let r = Rect::from_min_size(pos2(20.0, 20.0), vec2(24.0, 24.0));
icon_button_at(ui, ui.scope_id().with("b"), r, Icon::Eye, "Hide");
});
let row = painted_box(|ui| {
icons::button(ui, Icon::Eye, 24.0, false, "Toggle visibility");
});
for (name, b) in [("icon_button_at", at), ("icons::button", row)] {
assert!(header.is_finite() && b.is_finite(), "{header:?} {b:?}");
assert!((header.width() - b.width()).abs() < 0.5, "{name}: widths {} vs {}", header.width(), b.width());
assert!((header.height() - b.height()).abs() < 0.5, "{name}: heights {} vs {}", header.height(), b.height());
}
}
#[test]
fn glyph_rect_is_the_shared_inset_of_the_button() {
let b = Rect::from_min_size(pos2(0.0, 0.0), vec2(24.0, 24.0));
assert_eq!(icon_glyph_rect(b).size(), vec2(16.0, 16.0));
}
+2
View File
@@ -124,6 +124,8 @@ mod guide_release_tests;
#[cfg(test)]
mod guide_snap_tests;
#[cfg(test)]
mod header_icon_size_tests;
#[cfg(test)]
mod home_recents_tests;
#[cfg(test)]
mod home_tests;
+2 -2
View File
@@ -1157,7 +1157,7 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) {
if xr.hovered() {
ui.painter().rect_filled(x, CornerRadius::same(5), t.hover);
}
icons::paint(ui.painter(), x.shrink(6.0), Icon::Close, t.icon);
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(x), Icon::Close, t.icon);
if xr.clicked() {
remove = Some(l.id.clone());
}
@@ -1169,7 +1169,7 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) {
if sr.hovered() {
ui.painter().rect_filled(sw, CornerRadius::same(5), t.hover);
}
icons::paint(ui.painter(), sw.shrink(6.0), Icon::Swap, t.icon);
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(sw), Icon::Swap, t.icon);
crate::right::menu_popup(&sr).show(|ui| {
for other in doc.libraries.enabled.iter().filter(|o| o.id != l.id) {
if crate::right::menu_item(ui, &trf("Swap to {name}", &[("name", &other.name)]), "") {
+8
View File
@@ -92,6 +92,9 @@ impl CategoryFilter {
/// revision), the page or the choice change.
#[derive(Default)]
pub struct DevUi {
/// The layer whose new-note field takes the keyboard focus once its section draws (the
/// Annotation tool asked for that layer's note).
pub focus_new_note: Option<NodeId>,
pub filter: usize,
pub categories: CategoryFilter,
pub compare: Option<crate::dev_compare::CompareView>,
@@ -618,6 +621,11 @@ pub fn annotations_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: No
}
ui.add_space(6.0);
}
// The Annotation tool asked for this layer's note: put the caret in the new-note field, once.
if app.ui.dev.focus_new_note == Some(id) {
app.ui.dev.focus_new_note = None;
ui.memory_mut(|m| m.request_focus(Id::unique(("annot_new", id)).with("te")));
}
// A new annotation starts as a note; properties are pinned to it afterwards.
if n.annotations.len() < annotations::MAX_PER_LAYER
&& let Some(label) = widgets::multiline_field(ui, Id::unique(("annot_new", id)), "", tr("Add an annotation for developers"), 1)
+1 -1
View File
@@ -684,7 +684,7 @@ fn editor_popover(app: &mut FigApp, ctx: &egui::Context, popover: InteractionPop
if xr.hovered() {
ui.painter().rect_filled(x, CornerRadius::same(5), t.hover);
}
icons::paint(ui.painter(), x.shrink(6.0), Icon::Close, t.icon);
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(x), Icon::Close, t.icon);
if xr.clicked() {
close = true;
}
+2 -3
View File
@@ -456,7 +456,7 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
if sresp.hovered() {
ui.painter().rect_filled(sr, CornerRadius::same(5), t.hover);
}
icons::paint(ui.painter(), sr.shrink(5.0), Icon::Rotate, t.icon);
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(sr), Icon::Rotate, t.icon);
if sresp.clicked() {
app.run("frame.swapOrientation", json!({"ids": sel}));
}
@@ -511,8 +511,7 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
("bool.union", Some(op)) => crate::layers::op_icon(op),
_ => icon,
};
let icon_rect = Rect::from_min_size(br.min, vec2(24.0, 24.0)).shrink(5.0);
icons::paint(ui.painter(), icon_rect, icon, t.icon);
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(br), icon, t.icon);
if dropdown {
icons::paint(ui.painter(), Rect::from_center_size(pos2(br.max.x - 6.0, br.center().y), vec2(8.0, 8.0)), Icon::Chevron, t.icon_secondary);
}
+24 -2
View File
@@ -30,6 +30,9 @@ enum Target {
Proto(crate::proto_editor::VarSlot),
/// Bind it to `field` of the shared style `style`.
Style { style: String, field: String },
/// Bind it to the weight of the shared text style `style`: a string variable names the face
/// (`fontStyle`), a number one sets the weight (`fontWeight`).
StyleWeight { style: String },
}
/// The open variable picker: what it does, the variable type (None: any), where it opens.
@@ -197,6 +200,13 @@ pub fn open_style(ui: &Ui, style: &str, field: &str, kind: VariableType, at: Pos
ui.data_mut(|d| d.insert_temp(picker_id(), Picker { target, kind: Some(kind), at }));
}
/// Open the picker at `at` to bind a string (face name) or number (weight) variable to the weight of
/// the shared text style `style`.
pub fn open_style_weight(ui: &Ui, style: &str, at: Pos2) {
let target = Target::StyleWeight { style: style.to_string() };
ui.data_mut(|d| d.insert_temp(picker_id(), Picker { target, kind: None, at }));
}
/// Where the open picker is on screen (clicks in it don't close what opened it).
pub fn picker_rect(ctx: &egui::Context) -> Option<Rect> {
picker_open(ctx).then(|| ctx.memory(|m| m.area_rect(Id::unique("variable_picker_area")))).flatten()
@@ -398,7 +408,7 @@ pub fn picker(app: &mut FigApp, ctx: &egui::Context) {
let (scope, own) = match &p.target {
Target::Bind { ids, field } => (ids.first().and_then(|id| figcraft_engine::cmd::variables::field_scope(&doc, *id, field)), None),
Target::Alias { variable, .. } => (None, doc.variables.get_by_id(variable)),
Target::Proto(_) | Target::Style { .. } => (None, None),
Target::Proto(_) | Target::Style { .. } | Target::StyleWeight { .. } => (None, None),
};
// A style's fields offer variables scoped to one of the properties the field sets.
let style_scopes = match &p.target {
@@ -406,7 +416,8 @@ pub fn picker(app: &mut FigApp, ctx: &egui::Context) {
_ => None,
};
// Prototypes and styles use this file's variables.
let local_only = matches!(p.target, Target::Proto(_) | Target::Style { .. });
let local_only = matches!(p.target, Target::Proto(_) | Target::Style { .. } | Target::StyleWeight { .. });
let weight_target = matches!(p.target, Target::StyleWeight { .. });
let field = match &p.target {
Target::Bind { field, .. } => Some(field.as_str()),
_ => None,
@@ -417,6 +428,12 @@ pub fn picker(app: &mut FigApp, ctx: &egui::Context) {
&& (q.is_empty() || v.name.to_lowercase().contains(&q))
&& scope.is_none_or(|s| v.in_scope(s))
&& style_scopes.is_none_or(|list| list.iter().any(|s| v.in_scope(s)))
&& (!weight_target
|| match v.kind {
VariableType::String => v.in_scope("FONT_STYLE"),
VariableType::Number => v.in_scope("FONT_WEIGHT"),
_ => false,
})
&& (lib.is_some() || own.is_none_or(|o| figcraft_engine::cmd::variables::alias_target(&doc.variables, o, &v.id).is_ok()))
};
let groups = groups(&doc, &libs, keep);
@@ -495,6 +512,11 @@ pub fn picker(app: &mut FigApp, ctx: &egui::Context) {
(Target::Style { style, field }, _) => {
app.run("style.bind", json!({"style": style, "field": field, "variable": pick.id}));
}
(Target::StyleWeight { style }, _) => {
let number = app.session.ds().is_ok_and(|d| d.doc.variables.get_by_id(&pick.id).is_some_and(|v| v.kind == VariableType::Number));
let field = if number { "fontWeight" } else { "fontStyle" };
app.run("style.bind", json!({"style": style, "field": field, "variable": pick.id}));
}
}
close = true;
}
+2 -2
View File
@@ -626,8 +626,8 @@ fn flyout(app: &mut FigApp, ui: &mut Ui, g: &Group, bottom_left: egui::Pos2) {
/// Choose `tool` from the toolbar. Dev Mode only inspects: a tool that edits leaves it for the
/// design.
fn choose(app: &mut FigApp, tool: ToolId) {
if app.ui.right_tab == crate::RightTab::Inspect && !matches!(tool, ToolId::Move | ToolId::Hand | ToolId::Comment) {
pub(crate) fn choose(app: &mut FigApp, tool: ToolId) {
if app.ui.right_tab == crate::RightTab::Inspect && !tool.works_in_dev_mode() {
app.ui.right_tab = crate::RightTab::Design;
}
app.run("tool.set", json!({"tool": tool.id()}));
+12 -2
View File
@@ -1024,7 +1024,7 @@ pub fn section_header_resp(ui: &mut Ui, title: &str, buttons: &[(Icon, &str)], d
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);
}
icons::paint(ui.painter(), r.shrink(6.0), *icon, t.icon);
icons::paint(ui.painter(), icon_glyph_rect(r), *icon, t.icon);
if resp.clicked() {
out = Some(i);
}
@@ -1056,6 +1056,16 @@ pub fn tight_end_n<R>(ui: &mut Ui, count: usize, add: impl FnOnce(&mut Ui) -> R)
add(&mut child)
}
/// Inset of the glyph inside a 24 pt icon button: a 16 pt glyph, the icons' native size and what
/// `icons::button` draws (a fill row's eye), so every 24 pt icon button in the panels matches it
/// (section header buttons included).
pub const ICON_BUTTON_INSET: f32 = 4.0;
/// The rect a 24 pt icon button paints its glyph in.
pub fn icon_glyph_rect(button: Rect) -> Rect {
button.shrink(ICON_BUTTON_INSET)
}
/// An icon button at `rect` (hover highlight, tooltip); true when clicked.
pub fn icon_button_at(ui: &mut Ui, id: Id, rect: Rect, icon: Icon, tip: &str) -> bool {
let t = Tokens::get(ui.ctx());
@@ -1063,7 +1073,7 @@ pub fn icon_button_at(ui: &mut Ui, id: Id, rect: Rect, icon: Icon, tip: &str) ->
if resp.hovered() {
ui.painter().rect_filled(rect, CornerRadius::same(5), t.hover);
}
icons::paint(ui.painter(), rect.shrink(5.0), icon, t.icon);
icons::paint(ui.painter(), icon_glyph_rect(rect), icon, t.icon);
resp.clicked()
}
+7 -4
View File
@@ -726,6 +726,8 @@ struct TextBinding<'a> {
fn text_binding(ui: &mut Ui, binding: TextBinding<'_>, out: &mut Frame) {
let TextBinding { style, field, kind, rect, name, lead } = binding;
match widgets::bound_field(ui, Id::unique(("ts_bound", style, field)), lead, rect, name) {
// The weight slot takes a face name or a number, so its chip opens the picker that offers both.
Some(widgets::BindEvent::Open(at)) if matches!(field, "fontWeight" | "fontStyle") => crate::variable_ui::open_style_weight(ui, style, at),
Some(widgets::BindEvent::Open(at)) => crate::variable_ui::open_style(ui, style, field, kind, at),
Some(widgets::BindEvent::Detach) => out.act = Some(Act::Unbind(style.to_string(), field.to_string())),
None => {}
@@ -787,15 +789,16 @@ fn font_style_size_row(ui: &mut Ui, doc: &Document, style: &Style, def: &TextSty
let id = style.id.as_str();
let mut style_resp = None;
ui.horizontal(|ui| {
match bound_name(doc, style, "fontStyle") {
let weight_field = if style.bound.contains_key("fontWeight") { "fontWeight" } else { "fontStyle" };
match bound_name(doc, style, weight_field) {
Some(name) => {
let (r, _) = ui.allocate_exact_size(vec2(half, widgets::FIELD_H), Sense::hover());
text_binding(
ui,
TextBinding {
style: id,
field: "fontStyle",
kind: VariableType::String,
field: weight_field,
kind: if weight_field == "fontWeight" { VariableType::Number } else { VariableType::String },
rect: r,
name: &name,
lead: widgets::Lead::Named(tr("Font style")),
@@ -944,7 +947,7 @@ fn font_family_menu(ui: &Ui, id: &str, def: &TextStyle, family_resp: &Response,
fn font_style_menu(id: &str, def: &TextStyle, style_resp: &Response, out: &mut Frame) {
crate::right::menu_popup(style_resp).width(200.0).show(|ui| {
if crate::right::menu_item(ui, tr("Apply variable"), "") {
crate::variable_ui::open_style(ui, id, "fontStyle", VariableType::String, style_resp.rect.left_bottom());
crate::variable_ui::open_style_weight(ui, id, style_resp.rect.left_bottom());
ui.close();
return;
}
@@ -362,9 +362,19 @@ fn text_variables_session(bind: bool) -> crate::layers_drag_tests::Ui {
("Body tracking", "number", json!(1)),
("Body font", "string", json!("Inter")),
("Body weight", "string", json!("Bold")),
("Body weight number", "number", json!(700)),
] {
s.execute("variable.create", &json!({"collection": "Type", "name": name, "type": ty, "values": {"Base": value}})).unwrap();
}
for (name, scope) in [
("Body size", "FONT_SIZE"),
("Body leading", "LINE_HEIGHT"),
("Body tracking", "LETTER_SPACING"),
("Body font", "FONT_FAMILY"),
("Body weight number", "FONT_WEIGHT"),
] {
s.execute("variable.update", &json!({"variable": name, "scopes": [scope]})).unwrap();
}
if bind {
for (field, var) in [
("fontSize", "Body size"),
@@ -454,6 +464,54 @@ fn text_style_editor_shows_bound_fields_as_variables_and_detaches_them() {
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
}
#[test]
fn the_weight_menu_binds_a_number_variable_as_font_weight() {
let mut u = text_variables_session(false);
let left = editor_left(&mut u);
let in_editor = |u: &mut crate::layers_drag_tests::Ui| -> Vec<(String, Pos2)> {
drawn_texts(u).into_iter().filter(|(_, p)| p.x > left && p.x < left + W).collect()
};
let texts = in_editor(&mut u);
click(&mut u, text_at(&texts, "Regular"));
let texts = drawn_texts(&mut u);
click(&mut u, text_at(&texts, "Apply variable"));
assert!(crate::variable_ui::picker_open(&u.ctx), "the variable picker opens");
let texts = drawn_texts(&mut u);
assert!(texts.iter().any(|(t, _)| t == "Body weight"), "string variables are offered");
assert!(!texts.iter().any(|(t, _)| t == "Body font" || t == "Body leading"), "other fields' variables are not");
click(&mut u, text_at(&texts, "Body weight number"));
assert_eq!(bound_to(&u, "fontWeight").as_deref(), Some("Body weight number"));
assert_eq!(bound_to(&u, "fontStyle"), None);
let font_style = || {
let doc = u.app.session.doc().unwrap();
let Some(StyleKind::Text { style: def, .. }) = doc.style("Body").map(|s| s.kind.clone()) else { panic!("a text style") };
def.font_style
};
assert_eq!(font_style(), "Bold");
// The chip shows it in the style slot; clicking it lists the string variable too, and picking that
// replaces the number: fontStyle is bound and fontWeight is not.
for _ in 0..2 {
u.frame(vec![]);
}
let texts = in_editor(&mut u);
click(&mut u, text_at(&texts, "Body weight number"));
assert!(crate::variable_ui::picker_open(&u.ctx), "the chip opens the weight picker");
let texts = drawn_texts(&mut u);
assert!(texts.iter().any(|(t, _)| t == "Body weight"), "the string variable is offered beside the number one");
click(&mut u, text_at(&texts, "Body weight"));
assert_eq!(bound_to(&u, "fontStyle").as_deref(), Some("Body weight"));
assert_eq!(bound_to(&u, "fontWeight"), None, "the name replaced the number");
// The chip's detach button clears the binding.
for _ in 0..2 {
u.frame(vec![]);
}
let texts = in_editor(&mut u);
let chip = text_at(&texts, "Body weight");
click(&mut u, Pos2::new(left + (W - 8.0) / 2.0 - 11.0, chip.y));
assert_eq!(bound_to(&u, "fontStyle"), None, "the face name is detached");
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
}
#[test]
fn text_style_editor_binds_variables_from_its_fields() {
let mut u = text_variables_session(false);
@@ -477,7 +535,7 @@ fn text_style_editor_binds_variables_from_its_fields() {
click(&mut u, Pos2::new(x, y));
assert!(crate::variable_ui::picker_open(&u.ctx), "{field}: the variable picker opens");
let texts = drawn_texts(&mut u);
assert!(!texts.iter().any(|(t, _)| t == "Body font"), "{field} offers number variables only");
assert!(!texts.iter().any(|(t, _)| t == "Body weight"), "{field} offers number variables only");
click(&mut u, text_at(&texts, name));
assert_eq!(bound_to(&u, field).as_deref(), Some(name));
}
@@ -491,7 +549,7 @@ fn text_style_editor_binds_variables_from_its_fields() {
assert!(crate::variable_ui::picker_open(&u.ctx), "{field}: the variable picker opens");
let texts = drawn_texts(&mut u);
// "Body size" shows once, on the size field's chip: the picker doesn't list it.
assert_eq!(texts.iter().filter(|(t, _)| t == "Body size").count(), 1, "{field} offers string variables only");
assert_eq!(texts.iter().filter(|(t, _)| t == "Body size").count(), 1, "{field} does not offer the size variable");
click(&mut u, text_at(&texts, name));
assert_eq!(bound_to(&u, field).as_deref(), Some(name));
}
+3 -2
View File
@@ -1,6 +1,6 @@
# Boards parity
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-11-10 · **Change:** minor (a sticky's text stays 18 and the note grows taller instead of the text shrinking, as measured in FigJam's web app (#2157); mind maps, first step: a first node with three children on curved branches, laid out again after every edit (`board.mindMap`, `board.mindMapAdd`, #2226); a `.jam` elbowed connector saved with its path keeps it on import instead of being routed again (#1910); a "+" handle dragged carries the preview: the next shape is made where it is let go, or a connector joins the object it is let go on (#1919); a connector attached to a side starts 4 pt outside the border, so its stroke never lies on the fill (#1916); a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a connector end dropped along a side of a shape attaches to that side (#1708); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a connector's bar sets its start and end points, dashes, weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); "+" handles beside a selected sticky or shape make the next one, joined by a connector (#1597); a FigJam `.jam` file opens as a board (#1342); board grid settings: dots, lines, graph paper or none, spacing, size and contrast; the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); templates and tables; facilitation: a shared timer and voting sessions; the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre)
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-11-10 · **Change:** minor (a `.jam` file's stickies come in as native board stickies, with their colour, text and author kept and the size as saved (#1808); a sticky's text stays 18 and the note grows taller instead of the text shrinking, as measured in FigJam's web app (#2157); mind maps, first step: a first node with three children on curved branches, laid out again after every edit (`board.mindMap`, `board.mindMapAdd`, #2226); a `.jam` elbowed connector saved with its path keeps it on import instead of being routed again (#1910); a "+" handle dragged carries the preview: the next shape is made where it is let go, or a connector joins the object it is let go on (#1919); a connector attached to a side starts 4 pt outside the border, so its stroke never lies on the fill (#1916); a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a connector end dropped along a side of a shape attaches to that side (#1708); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a connector's bar sets its start and end points, dashes, weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); "+" handles beside a selected sticky or shape make the next one, joined by a connector (#1597); a FigJam `.jam` file opens as a board (#1342); board grid settings: dots, lines, graph paper or none, spacing, size and contrast; the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); templates and tables; facilitation: a shared timer and voting sessions; the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre)
**Breadth ~47%, ready ~29% (estimated, erring low).** A board is a FigCraft file of its own kind
(`file.newBoard`) with a dot grid on the page, sticky notes, shapes with text, connectors that
@@ -66,7 +66,7 @@ all end with the same geometry.
| Audio and huddles | missing | |
| AI features (summaries, sorting stickies, generating boards) | missing | |
| Grid on the page | done | the canvas render draws it after the page colour and before the layers (`figcraft_render::Dots`), so objects cover it. Dots by default, or lines, graph paper (a strong line every 2–12 cells over fainter ones, like Japanese section paper) or none; spacing (4–512), mark size and contrast are settings kept with the preferences (`board.grid`; Preferences ▸ Board grid). Zoomed out it thins out (dots and lines by halves, graph paper down to its strong lines) until the marks are at least 14 points apart (10 for lines), the marks the next step drops fading out first; dots stay about a point and a half across on screen (1 to 3 points), scaled by the size setting. Dark marks on a light page, light ones on a dark page, from the theme's ink; written straight into the image, no allocation per mark, a crossing of two lines blended once; never in exports. FigCraft's own settings: the incumbent's board grid has none |
| `.jam` import and export | **partial**: a `.jam` opens as a board, best effort (#1342): its stickies and shapes with text come in as frames with their generated layers and its connectors as vectors, not as native board objects (an elbowed connector keeps the path it was saved with, #1910); no export | see [`file-format-parity.md`](file-format-parity.md) |
| `.jam` import and export | **partial**: a `.jam` opens as a board, best effort (#1342): its stickies come in as native stickies (their colour, text and author kept; size as saved), its shapes with text as frames with their generated layers and its connectors as vectors, not yet as native board objects (an elbowed connector keeps the path it was saved with, #1910); no export | see [`file-format-parity.md`](file-format-parity.md) |
## Revision history
@@ -75,6 +75,7 @@ all end with the same geometry.
| 2026-11-09 | patch | M12, #2157: a sticky's text is 18 and stays 18 (measured in FigJam's web app: ten lines, then a long paragraph, never changed it; the note kept its width and grew taller). The normalize pass no longer resets a sticky's text to a size by its length (24 down to 11 before), so stickies in saved files keep their size. Size presets (Small to Huge) are still missing. Tests: `a_new_sticky_starts_at_18`, `a_sticky_keeps_its_text_size_as_text_is_added`, `a_sticky_saved_with_another_text_size_keeps_it` |
| 2026-11-10 | minor | M12, #2226 (step 1 of 4): mind maps in the document and engine. A mind map node is a board object of its own (`BoardItem::MindNode`: text with no box) and a branch is a connector marked `branch`; the map's shape is read from its branches. `board.mindMap` places a first node with three children and `board.mindMapAdd` adds a child or a sibling, each one undo step. The normalize pass lays a touched map out again from its first node (`board::settle_mind_maps`, `figcraft_doc::mind_layout`): 88 between columns, 48 between siblings, each stack centred on its parent, numbers from a FigJam file saved and decoded. Tests: `board_mindmap_tests` in doc (five) and engine (eight). Numbers unchanged (no toolbar entry yet). |
| 2026-11-03 | minor | M12, #1919: as the quick create help says ("Click to select and create the next shape in that direction, or drag the preview to any location on your board"; "A new shape is created with a connector and its text field active"), a "+" beside the selected sticky or shape can be dragged: the preview follows the pointer and, let go on empty board, the next one is made there, centred on the pointer, and joined by a connector leaving by the handle's side and arriving by the side facing it (`board.addConnected` gains `at: [x, y]`, a point of the page placed in the parent's coordinates); let go on another sticky or shape, inside a section or frame too (`board::sticky_or_shape_at`), a connector joins the two instead (`board.connector`), that object outlined while the pointer is over it; let go off the canvas or over the toolbar, nothing, and the bar above the selection stays away meanwhile, as for any drag. The click is as before. A shape picker on drop and choosing the connector type are not done (the help doesn't describe them). Tests: `board_connect_tests` (the drag ones), engine `board_connected_tests::the_next_one_goes_where_it_is_dropped`. Numbers unchanged. |
| 2026-11-10 | minor | M9, #1808: a `.jam` sticky imports as a native board sticky (`BoardItem::Sticky`): its frame keeps the saved size (240 pt, not FigCraft's 200), place, opacity and shadows, the saved colour is kept as it is (not snapped to the board colours), the note and the author line are its text layers, and it lays out like one made here (vertical, space between, hugging the height down to the saved size, the saved inset as padding). The importer reads a `board` field on the REST JSON (a FigCraft extension) for this; a sticky whose generated records are damaged, or the same sticky in a `.fig`, keeps the generated layers as before. Shapes, connectors, stamps and tables are unchanged; the note's text keeps the file's size until it is edited. Tests: `board_import_tests.rs` (native sticky, no author, damaged records, `.fig` unchanged), `import_regression_tests.rs` (the `board` field and junk values). Numbers unchanged. |
| 2026-11-04 | minor | M12, #1916: a connector end attached to a side starts 4 pt outside the border (`board_route::END_GAP`), as FigJam's do (the issue's screenshots: the line starts a few pixels outside, with a round cap), so the stroke never lies on the fill; elbows leaving one side share their first run along its normal, curves leave tangent to it and straight ones fan out from the same point; the `.jam` importer's routes, re-attached ends, path handles and labels follow the route's points; an end placed by hand with Cmd/Ctrl held keeps its exact point. Tests: `board_route_tests` (three). Numbers unchanged. |
| 2026-11-04 | patch | M6, #1910: a `.jam` elbowed connector saved with its path (FigJam's `connectorControlPoints`) keeps it on import: the points become the `ConnectorPath::Runs` a connector reshaped by hand has and the line is walked along them, so a connector drawn around a layer stays around it; one whose points don't make a path from its ends' sides is routed as before. Still a vector, not a native board object. Details in [`file-format-parity.md`](file-format-parity.md). |
| 2026-10-31 | minor | M12: as the help describes, holding Cmd/Ctrl while moving a connector's end places it on any part of an object instead of snapping to a side. It keeps that spot as a fraction of the object's box, so it follows the object when it moves or resizes, and without the key an end snaps as before. `board.connector {fromAt, toAt}` and `board.reattach {at}` take it; saved only when set. Numbers unchanged |
+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 (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-07 · **Change:** minor (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
@@ -65,7 +65,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
| Paint, text, effect, grid styles; style picker; text styles on character ranges | done | behaviour suite: `styles_behaviour_tests.rs`; a text style carries paragraph spacing and indent, the list style and the OpenType features beside the character properties (`paragraph` next to `style` in `style.list`): applied with it, moved by `style.update`, unlinking when edited, kept by `.fig` files (#1829) |
| Editing a style updates its layers; detach and delete keep values; editing a styled property unlinks that style | done | an edited fill/stroke/effect/grid list or text chars unlink; applying a style replaces variables bound to what it sets; text styles on text layers only |
| Organising styles: folders by `/` names, order, duplicate, rename folder, Local styles list | done | `style.move` (into a folder renames), `style.duplicate` (right below), `style.renameFolder`; the page's Local styles list by type and folder (drag to reorder, right-click) |
| Variables inside styles (paint, effect, grid and text styles; each user resolves them in its own modes) | done | `style.bind`, `style_vars_tests`; the editor binds paint colours, shadow colours, blur radii, grid sizes and a text style's font, font style, size, line height and letter spacing, the rest through the command |
| Variables inside styles (paint, effect, grid and text styles; each user resolves them in its own modes) | done | `style.bind`, `style_vars_tests`; the editor binds paint colours, shadow colours, blur radii, grid sizes and a text style's font, font style (a face name from a string variable, or a numeric weight from a number variable, which picks the nearest face and a variable font's `wght` axis), size, line height and letter spacing, the rest through the command; a bound line height is applied in pixels, as the reference tool does (measured 2026-11-10) |
| Variables: colour, number, string, boolean; collections, modes, aliases, scopes, groups | done | behaviour suite: `variables_behaviour_tests.rs`; aliases same type, no loops (also on create); colour calculations per mode (`{alpha}`: a colour variable with its alpha replaced, `{mix}`: a per-channel blend of two colour variables or literals; they compose, can't loop, and bound layers, modes and undo follow them; REST, CSS, Penpot and `.fig` exports write the colour they show in each of their collection's modes, other collections in their default modes; read-only in the panel, which can swap one for an alias or detach it; `variables_colour_tests.rs`), beyond the incumbent, whose variables only alias; scopes filter pickers only; several variables can be selected in the editor (Shift and Cmd/Ctrl click) and deleted, moved to a group or rescoped at once |
| Modes: add, duplicate, rename, delete, set as default / reorder | done | layers on Auto follow a new default; explicit modes stay |
| Deleting a bound variable or its collection | done | layers keep their last value and are unbound; aliases to it take its values and colour calculations reading it keep the colour they show |
@@ -81,6 +81,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
| Date | Change | Summary |
|---|---|---|
| 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 |
| 2026-11-02 | minor | M3, #1829: text styles carry paragraph spacing and indent, the list style and the OpenType features ("Supported in text styles": "Paragraph spacing and indentation", "Lists", "OpenType features", Create and apply text styles) beside the character properties: `style.create` takes them from the layer (or the selected paragraphs' list) or from a `value` (`paragraphSpacing`, `paragraphIndent`, `listStyle`, `features`), `style.apply` sets them (on a range, the list goes to the paragraphs touched; the rest is the layer's, as in Figma), `style.update` moves them on every linked layer, editing one on a styled text unlinks it as editing its font does, the style editor lists them, and a `.fig` file keeps them with the style (its sample layer). Grade unchanged. |
+3 -2
View File
@@ -1,6 +1,6 @@
# Dev Mode parity
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-11-07 · **Change:** patch (the handoff mode reads Developer Mode in the interface, its ids and saved fields unchanged; class names transliterate non-ASCII layer names (#1235); measurements kept on the canvas; a read-only layers panel in Dev Mode; earlier: import lines in generated layer trees; compare with any saved version; Dev Mode behaviour suite: an inspect-only canvas, Changed in the ready list, statuses only on frames and sections; plugin panels from the Inspect tab) · **Target:** Figma Design Dev Mode (desktop app 126.10.7)
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-11-09 · **Change:** patch (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 Inspect tab then opens (never toggles away) with the caret in that layer's new-note field, and the engine never writes the note itself (#1840); the handoff mode reads Developer Mode in the interface, its ids and saved fields unchanged; class names transliterate non-ASCII layer names (#1235); measurements kept on the canvas; a read-only layers panel in Dev Mode; earlier: import lines in generated layer trees; compare with any saved version; Dev Mode behaviour suite: an inspect-only canvas, Changed in the ready list, statuses only on frames and sections; plugin panels from the Inspect tab) · **Target:** Figma Design Dev Mode (desktop app 126.10.7)
**Breadth ~98%, ready ~78% (estimated).** The inspect surface is broad, components connect
to the code components of a repository (with their imports, also inside generated layer trees),
@@ -15,7 +15,7 @@ repository, third-party integrations. Remaining: 1–2 h.
| Properties with their styles and variables; redlines on hover | done | positions from the frame around the layer (groups aren't frames), rotation, radius (CSS corner order), opacity, blend; colours in hex, RGB, CSS, HSL or HSB (`inspect.properties {colorFormat}`, the Inspect tab's Color format) |
| Code: CSS, iOS (SwiftUI), Android (Compose), Flutter | done | plus HTML, React, Vue and Leptos (ours; Leptos: `#[component]` functions with `#[prop]` defaults, variant enums and `view!` markup; Flutter is ours too: a `StatelessWidget` for a component, a widget tree for a layer); components take properties as parameters, instances are calls; CSS in px or rem (`inspect.code {unit, rootFontSize}`), with rotation, blend modes, gradient directions and kinds (linear, radial, conic), text alignment, case and decoration; class names from layer names transliterate non-ASCII scripts into readable ASCII (로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber`; symbols-only names take the node's kind, `.text`), and the 40-character cut lands on a word boundary (#1235) |
| Assets list with export | done | layers with export settings, icons (small, vector shapes only: SVG) and images (PNG); the icon rule is ours |
| Annotations: pinned properties, categories, coloured callouts, filter | done | |
| Annotations: pinned properties, categories, coloured callouts, filter | done | the Annotation tool (Shift+T, `tool.annotation`) picks a layer by a click or from the selection and opens its note field in the Inspect tab. Figma's placement, cursor and whether the tool stays selected are unmeasured (Developer Mode is paywalled in the web app); staying selected until V or Escape, like the Comment tool, is inferred, and there is no toolbar button or icon yet |
| Ready for dev list, statuses, "Changed" after edits | done | the list filters Ready for dev, Changed, Completed or any, and badges changed ones (`inspect.readyForDev {status: changed}`, `changed` per item); only sections and frames at the top of a page or section take a status (others are refused) |
| Focus view (one frame, previous / next) | done | |
| Compare changes (before/after side by side or overlaid, property diffs) | done | against the snapshot taken when a frame was first changed after it was marked ready, or any saved version (`dev.compare {version}`; the view's Compare with menu); a frame that isn't ready for dev compares with versions |
@@ -29,6 +29,7 @@ repository, third-party integrations. Remaining: 1–2 h.
| Date | Change | Summary |
|---|---|---|
| 2026-11-09 | patch | The Annotation tool (Shift+T, `tool.annotation {id?}`): selects the layer pressed on (or the selection, or `id`) and asks the UI for its note, which the Inspect tab takes in its new-note field; the engine writes nothing itself, and Developer Mode lets the tool through its gates (#1840). Figma's placement, cursor and whether the tool stays selected are unmeasured (Developer Mode is paywalled in the web app); staying selected until V or Escape, like the Comment tool, is inferred, and there is no toolbar button or icon yet |
| 2026-10-30 | minor | Gap 16: `figcraft-cli connect publish <file.figcraft> [--mappings figcraft.connect.json] [--dry-run]` publishes a code repository's mappings to a design file (as the incumbent's Code Connect CLI publishes a repository's mappings): it links each entry's component or set by name, warns about and skips links that don't fit, saves the file in place through the engine's save (or not, with `--dry-run`) and fails when nothing links. Dev Mode breadth (~98%) and ready (~78%) unchanged at this rounding; hours unchanged |
| 2026-10-26 | minor | Class names from layer names transliterate non-ASCII scripts (#1235): `class_name` runs names through `deunicode` (new dependency: pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`; before, every one of them came out `layer-`); symbols-only names take the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. ASCII-only names are unchanged. `html_codegen_tests` updated (its diacritics expectation improves from `layer-1-n-code-name` to `layer-1-unicode-name`) and extended. Percentages and hours unchanged |
| 2026-10-24 | minor | Dev Mode refuses design edits from files dropped on the canvas (a dropped document still opens as a file of its own, and the drop overlay shows the Dev Mode note for other files), from the Pages list's add, rename, reorder, duplicate and delete, and from the Assets panel's place, drag, Alt-drop swap, style, Hide when publishing, Update, and adding, removing and swapping libraries. Percentages and hours unchanged |
+3 -2
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (`.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; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editaLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (`.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; `.fig` export: written files checked by opening them in other software; experimental `.fig` exportLine 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
@@ -20,7 +20,7 @@ and prototype interactions are still unchecked there.
| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks with region-specific fill paints (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides; an instance scaled with the Scale tool, which stores `symbolData.uniformScaleFactor`, draws its layers at the main's lengths times that factor, composed through nested instances: sizes and places, corner radii, stroke weights and dashes, effect radii, spreads and offsets, auto layout gaps and paddings, grid gaps, min and max sizes, layout grids, font sizes, pixel line heights and letter spacings, paragraph spacing and indent, except where an override or derived row gives the value, which is already at the instance's scale; per-point vector corner radii also take their vector-data row's factor; the vector outline paths a layer carries (a network's points follow the layer's size), fixed grid tracks, image tile scales and glass effects are not scaled by the factor, #2091), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes and shapes with text keep their generated backgrounds, styled text, text paints, saved child bounds and sticky author lines as editable frame children, including saved vector networks without geometry paths, outer fill/stroke outlines when generated geometry is damaged, and outer body text when its generated child is unusable even if an author survives (#1341, #1397); connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message; since 2026-10-29, style links that name a library style by asset key and version (the file's own copy of it), with override rows above the linking one still winning, and override rows restating a nested level's overrides applied at that level, below the instance's own (#1234), and the line breaks the file saved with a text, followed until an edit or a resize could move one, or where a line would come out more than 3% wider than its box in the fonts at hand (#1236). Budgets come from the file's own size (four decoded values per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows the file's size too), so a 15.4 MB synthetic file with 300,000 layers opens whole; ZIP image reads have a separate total budget of 4 GiB (1 GiB on 32-bit targets) of decompressed ZIP-member bytes containing encoded images, with the 256 MiB per-image cap kept: a member that cannot fit is skipped before its output buffer is allocated, and the import report names the budget and counts skipped images (#1076). Failed reads and invalid image contents still consume the bytes read; a reference used by several layers is read once. This is not a limit on decoded pixels or total import memory; images carried inside the message are unchanged. The file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Elbowed and curved connectors without a saved path are routed as FigCraft's board connectors are (right angles, or one curve, from the sides of the layers they join); an elbowed one saved with its path (`connectorControlPoints`: a point and an axis per run between the first and the last) keeps that path when it leaves and arrives by the ends' sides (#1910), and a curved one's saved `connectorBezierControlPoints` are not read; a connector routLine truncated
| `.penpot` (Penpot 2.x ZIP export) | — | **yes, offline** (open, import, drop, CLI, MCP) | **yes, offline** (export, CLI convert) | Editable layers, supported flex/grid layout, components, tokens and prototypes using the existing model. Unsupported layouts retain saved positions and bounded metadata; toggle-overlay and embedded font binaries are reported. Font references remain editable; no downloads. Synthetic regression coverage; live Penpot and cross-platform QA remain open. See [Penpot reference](penpot.md). | further live Penpot and cross-platform QA |
| `.sketch` (Sketch 48 and newer) | import (file browser ▸ Import or drop, desktop app too; converted to a Figma file, symbols as components; per the help center) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP; Open With from Finder, Explorer and Linux file managers once installed; recent files show its `previews/preview.png`) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, stretching filling every item across, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides; from Sketch 64, before which a group's fill draws nothing), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); operands' own borders ignored as Sketch does; open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (their image under their style's fills; colour adjustments as image filters with Sketch's tone curve, contrast and then brightness added), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients with Sketch's geometry, gradients mixed in OKLab or OKLCH as sRGB stops along the same colours, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer, motion and background blurs (Sketch's radius is the standard deviation), opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does, and its opacity fades it and what it clips together; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, also on shapes' image fills, layer and text styles, nested paths), clipping only where the master clips (`clippingBehavior` 1), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only when the master includes them in both instances and exports, as on Sketch's canvas and renders; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids (hidden, as Sketch shows them only in its layout view). Instances of a stack laid out at their size and texts an override changed measured again on open, an auto-width one growing from its aligned edge. Warnings: noise fills, OKLab and OKLCH gradients, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 68 and 83 (70,000 layers open in ~3 s, debug build; its edition saved by Sketch 47 is refused, as it should be); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 69 1.1% on average and at most 4.4% (text in IBM Plex, which the file embeds and FigCraft doesn't use yet), a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcaseLine truncated
| `.jam` (whiteboards) | native to FigJam | **yes, best effort** (`file.open`, `file.import`, File ▸ Open, drop, CLI, MCP) | no | the same archive as a `.fig` (its canvas header reads `fig-jam.`) through the same reader (#1342): it opens as a board (the file kind; a `.fig` stays a design file), with generated sticky and shape backgrounds and text kept (#1341) as ordinary layers rather than native board objects (see [boards-parity](boards-parity.md)); tables, stamps, code blocks and the like come in as frames with their text and connectors as vectors (an elbowed connector keeps the path it was saved with, #1910), with an import note; a layer type the reader doesn't know is left out with a note naming it; writing `.jam` is not supported | — |
| `.jam` (whiteboards) | native to FigJam | **yes, best effort** (`file.open`, `file.import`, File ▸ Open, drop, CLI, MCP) | no | the same archive as a `.fig` (its canvas header reads `fig-jam.`) through the same reader (#1342): it opens as a board (the file kind; a `.fig` stays a design file), with stickies as native board stickies (their colour, text and author kept, size as saved, #1808) and generated shape backgrounds and text kept (#1341) as ordinary layers rather than native board objects (see [boards-parity](boards-parity.md)); tables, stamps, code blocks and the like come in as frames with their text and connectors as vectors (an elbowed connector keeps the path it was saved with, #1910), with an import note; a layer type the reader doesn't know is left out with a note naming it; writing `.jam` is not supported | — |
| `.deck` (Slides) | native to other Figma products | no | no | out of scope | — |
| REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — |
| `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`); image members are `images/<key>`, and a key that can't be one — path text, a space, over 128 chars — is left out with a complaint instead of loaded (`image_tests`); saves write a unique staging file and rename it into place (#284); optional auto-save of files with a path (`pref.autoSave`, #305); Save and auto-save refuse to write over a file another app (figcraft-cli, say) changed since it was opened or saved here, judged by its size and modification time: Revert to saved loads those changes, Save as keeps both, `file.save {overwrite: true}` replaces them (#1330); `kind: "board"` marks a board (left out for design files, so files from before boards are design files; the home screen reads it for the file's mark); board objects keep their `board` metadata on the layer; Developer Mode measurements are kept on the layer they start from (`measurements: [{to} | {side}]`, left out when there are none, so older files open with none) | — |
@@ -202,6 +202,7 @@ scales and empty-style clears; they have not been checked in other software.
| 2026-11-07 | minor | `.fig` import, #2091 item 1: an instance scaled with the Scale tool stores `symbolData.uniformScaleFactor`, which the import never read, so the layers of a 128 px instance of a 256 px component came in at 256 px with the main's radii, strokes and spacing. The layers of an instance now take the main's lengths times the factor (composed through nested instances, so 0.5 inside 0.5 is 0.25 for the inner layers), the lengths the Scale tool scales: sizes and places, corner radii, stroke weights and dashes, effect radii, spreads and offsets, auto layout gaps and paddings, grid gaps, min and max sizes, layout grids, font sizes, pixel line heights and letter spacings, paragraph spacing and indent. A value an override or derived row gives is taken as written, already at the instance's scale; the instance's own size is the file's; a missing, non-finite, non-positive or out-of-range factor (outside 0.001 to 1000) changes nothing. Not scaled: vector outline paths (a network's points follow the layer's size), per-point corner radii, fixed grid tracks, image tile scales and glass effects. Tests: `fig::tests::a_scaled_instance_draws_its_layers_at_the_factor` and `fig::scale_factor_tests`. Numbers unchanged |
| 2026-11-03 | patch | M6, #1909: `.jam` import: a connector end whose magnet is `AUTO` (FigJam picks the side when it draws) or `CENTER` (the end aims at the layer's middle) left the side its stale `relativePosition` named, the left one in the reporter's file, so the connectors looped behind their shape or crossed it. It now leaves the side facing the other end, as a board connector drawn here does (`Side::Auto`), for straight, elbowed and curved connectors alike; a named magnet (`TOP`, `RIGHT`, `BOTTOM`, `LEFT`) is unchanged, and an end without a magnet still takes the edge its point lies on. The Plugin API lists the magnets (`ConnectorEndpoint.magnet`: "Which side of the node it attaches to"; NONE, AUTO, TOP, LEFT, BOTTOM, RIGHT, CENTER; "Straight connectors may use only CENTER or NONE magnets"). Test: `connectors_with_an_automatic_side_leave_the_side_facing_the_target`. Numbers unchanged. |
| 2026-11-04 | minor | JPG export: `quality` (1 to 100, default 90) on `file.export`, `export.add` / `export.update` and MCP `export`, kept in the export setting; an sRGB file's JPGs embed the sRGB ICC profile, as Display P3 files' JPGs already embedded theirs, since an untagged JPG is only assumed to be sRGB (#2004). |
| 2026-11-10 | minor | M9, #1808: `.jam` import: a FigJam sticky comes in as a native board sticky. The mapper knows the canvas header (`fig-jam.`) and, for a whiteboard only, writes a `board` field (`{"type": "sticky", "author": ..}`, a FigCraft extension of the REST JSON) on a sticky whose generated records are complete (layout version 1, one visible solid background paint without strokes, a non-blank note), with the background's colour and corner radius, the file's size, place, opacity and shadows, the note and the author line as text layers and no background vector; the REST importer sets it as the layer's board item and ignores a junk `board` value with a warning. A sticky that isn't complete, or any sticky in a `.fig`, keeps the generated layers and the old note. Shapes with text, connectors, stamps and tables are unchanged. Tests: `fig::tests::board` (four), `rest::import_regression_tests` (two). |
| 2026-11-04 | minor | `.fig` and `.jam` import, #1910: an elbowed connector saved with its path keeps it. FigJam stores `connectorControlPoints`, one per run between the first and the last, each a point on its run (in the connector's parent's coordinates) with the run's axis; the first run leaves the start's side and the last arrives at the end's. They become the `ConnectorPath::Runs` a board connector reshaped by hand has (`figcraft_doc::route_along`), so a connector drawn around a layer in FigJam stays around it instead of being routed through it. Points that don't make such a path (off both axes, two runs in a row the same way, a first or last run sideways to its side or going back into its object, more runs than a hand could make) leave the connector to the router as before, and only connectors routed here count in the "follow FigCraft's own routing" note. A curved connector's `connectorBezierControlPoints` are still not read. Tests: `tail_tests::an_elbow_connector_saved_with_its_path_keeps_it`, `tail_tests::an_elbow_whose_saved_path_doesnt_fit_its_sides_is_routed_here`. |
| 2026-11-01 | patch | `.fig` and `.jam` import: a connector without a saved snapshot (elbowed and curved ones are routed here) took no stroke, so it was invisible (#1807); its line's paint, weight, cap and dashes now come from the file's generated line record, as a saved snapshot's do, and the connector's own values, when it has any, win. Test: `fig::connector_label_tests::a_routed_connector_takes_its_line_style_from_the_generated_record`. Numbers unchanged |
| 2026-10-31 | minor | Export settings: each PNG, JPG and WebP setting has a color profile, Same as file (the default; the document's profile when it exports), sRGB or Display P3, in its advanced settings and as `colorProfile` on `export.add`, `export.update`, `file.export`, `file.exportLayers` and MCP `export`. The pixels convert to the chosen profile (out-of-gamut colors clip) and the file is tagged as before: sRGB PNG with an `sRGB` chunk, Display P3 with the generated ICC profile (and `cICP` in PNG); sRGB JPG and WebP stay untagged (builds before 2026-11-04; from that date sRGB JPGs embed the sRGB ICC profile, see the 2026-11-04 JPG export entry). A setting keeps its choice when its format changes; settings saved without one read as Same as file. SVG, PDF and XML show no choice and stay sRGB (PDF has no output intent yet). Numbers unchanged |
+3 -2
View File
@@ -1,6 +1,6 @@
# Hardware and platform parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** patch (Windows: the custom title bar's windows stay borderless, so the content is no longer drawn about 30 px low with clicks landing above it (#1343); under Wayland a minimised window answers control and MCP requests in about half a second instead of timing out after 60 s (#1906); images copied in other apps paste in the browser (#492); text reads only the fallback fonts it draws with; `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); a batch keeps one undo snapshot, not one per step (#1099); one depth cap bounds a whole render, pattern sources included, and render threads get 8 MiB stacks (#1082); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (unused; zoomed-out images draw from small copies that stay decoded, so a page of photos zooms out without decoding them again; under Wayland a minimised window answers control and MCP requests in about half a second instead of timing out after 60 s (#1906); images copied in other apps paste in the browser (#492); text reads only the fallback fonts it draws with; `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); a batch keeps one undo snapshot, not one per step (#1099); one depth cap bounds a whole render, pattern sources included, and render threads get 8 MiB stacks (#1082); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels) · **Target:** Figma Design (desktop app 126.10.7)
How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is
Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out
@@ -12,7 +12,7 @@ since 2025). FigCraft renders the canvas on the CPU and presents it through wgpu
| Feature | Figma | FigCraft (macOS / Windows / Linux / web) | Hours |
|---|---|---|---|
| Canvas rendering | GPU | CPU (tiny-skia), banded across cores, retained (re-renders only what an edit or pan touches), levels of detail below 50% zoom, large images drawn from a copy no larger than they show (#1483); when renders from scratch are slow, a new zoom shows the whole view coarsely first, then refines it (#312); shown through wgpu (WebGPU or WebGL2 in the browser) | 30–60 for a GPU path |
| Canvas rendering | GPU | CPU (tiny-skia), banded across cores, retained (re-renders only what an edit or pan touches), levels of detail below 50% zoom, images drawn from the smallest copy that holds the size they show, down to 256 px (#1483); when renders from scratch are slow, a new zoom shows the whole view coarsely first, then refines it (#312); shown through wgpu (WebGPU or WebGL2 in the browser) | 30–60 for a GPU path |
| Effects (blurs, glass, noise) | GPU | CPU | in the above |
| HiDPI / Retina | yes | yes (all); a canvas image rendered at the screen's density is placed on whole device pixels, so fractional pans, zooms and display scales don't blur it (#359; geometry tests, the snapping not judged by eye) | — |
| Wide-gamut display (Display P3) | yes, on capable displays | colours stored and exported in P3; the screen shows sRGB: eframe/egui-wgpu 0.37 configure an 8-bit sRGB surface with no colour space option, although wgpu 30 can present Display P3 or extended sRGB on Metal, DX12, Vulkan and WebGPU (see gaps #18). The colour picker warns about colours outside sRGB and View ▸ Soft-proof sRGB stripes them on the canvas | 2–5 |
@@ -50,6 +50,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import).
| Date | Change | Summary |
|---|---|---|
| 2026-11-10 | minor | Zoomed-out images: the canvas's copies of an image shown smaller than its pixels now also come at 256, 512, 1024 and 2048 px, the smallest that holds what the screen shows, and images of 4096 px or less get them too (they were drawn from the image itself at any size). On a page of 237 photos, about 4.6 GB decoded against the 512 MB cache, every zoomed-out render decoded them again: 3.4 s a frame at 1% before, 0.9 s the first time and 4 ms after; peak memory 3.9 → 2.3 GB. Exports, and images shown at their own size or larger, still draw from the image itself. |
| 2026-11-10 | patch | M10, #1343 (also #1422, #2233): Windows: the window no longer draws below a hidden caption. FigCraft draws its own title bar on Windows and opens undecorated windows, but winit 0.30 still gives them `WS_CAPTION` and `WS_BORDER`; on some systems the desktop compositor reserves that caption, so the content was drawn about 30 px low (a black band above it) while clicks kept the client coordinates and landed above what was drawn. A per-frame step (`apps/figcraft/src/window_frame.rs`, safe `winsafe` calls, Windows only) clears `WS_CAPTION` and `WS_BORDER` from every FigCraft window, the main one and the others, keeping the sizing border and window buttons so edge resizing and snap work, and writes only when a style changed (winit puts them back on maximize and restore). The same fix as DesignCraft #2139 and PhotoCraft #2401; the maximize-to-work-area trim from DesignCraft is left for a later change. Not run on a Windows machine that shows the offset: the style math and the per-frame hook are tested, the window itself needs a live check. Tests: `window_frame_tests`. Numbers unchanged |
| 2026-11-06 | patch | M8, #1906: under Wayland (seen on GNOME and KDE Plasma) a minimised FigCraft window still reports itself visible and the compositor stops asking it to draw, so every control or attached-MCP request waited for a frame and timed out after 60 s ("it was not applied"), though eframe kept running the app's logic. `control::background` now treats a window that drew no frame for `NOT_DRAWING_AFTER` (0.2 s) while a request waited for it as hidden: state-only requests are answered from the logic passes (0.54 s each, measured on GNOME Wayland; they took 13.7 s or timed out before), the wait starts again when the window draws, and screenshots and `ui.input` still wait for a frame. Test: `idle_tests::a_window_that_stopped_drawing_answers_from_its_logic_passes` |
| 2026-10-31 | minor | M1, #492: Web row: the browser pastes images copied in other apps, read from the paste event under the same limits as the desktop. Numbers unchanged. |
+3 -1
View File
@@ -1886,7 +1886,9 @@ properties with their values, its code and, with `path`, a PNG of it. The docume
unbinds): `fill` or `fills/<i>/color` and `fills/<i>/stops/<j>/color` on a paint style,
`effects/<i>/<color|radius|spread|offsetX|offsetY|startRadius>` on an effect style,
`layoutGrids/<i>/<size|count|gutter|offset>` on a grid style, `fontSize`, `lineHeight`,
`letterSpacing`, `fontFamily`, `fontStyle` on a text style. Each layer using the style shows the
`letterSpacing`, `fontWeight` (numbers; a weight picks the family's face nearest it and sets the
`wght` axis of a variable font), `fontFamily`, `fontStyle` (strings; a bound weight and a bound style
name replace each other) on a text style. Each layer using the style shows the
field in its own modes and stays linked; `style.list` gives a style's `bound` map.
`effect.add {ids?, type? | effect?}` adds an effect (`dropShadow`, `innerShadow`, `layerBlur`,
+2 -1
View File
@@ -1,6 +1,6 @@
# FigCraft roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (mind maps, first step: a first node with three children on curved branches, laid out again after every edit (`board.mindMap`, `board.mindMapAdd`, #2226); the handoff mode reads Developer Mode in the interface, its ids and saved fields unchanged; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); 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; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); every file a command reads whLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (zoomed-out images draw from small copies that stay decoded, so a page of photos zooms out without decoding them again; mind maps, first step: a first node with three children on curved branches, laid out again after every edit (`board.mindMap`, `board.mindMapAdd`, #2226); the handoff mode reads Developer Mode in the interface, its ids and saved fields unchanged; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); 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; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer'sLine truncated
Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and
[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours
@@ -76,6 +76,7 @@ blockers.
| Date | Change | Summary |
|---|---|---|
| 2026-11-10 | minor | Zoomed-out images: the canvas's copies of an image shown smaller than its pixels now also come at 256, 512, 1024 and 2048 px, the smallest that holds what the screen shows, and images of 4096 px or less get them too (they were drawn from the image itself at any size). On a page of 237 photos, about 4.6 GB decoded against the 512 MB cache, every zoomed-out render decoded them again: 3.4 s a frame at 1% before, 0.9 s the first time and 4 ms after; peak memory 3.9 → 2.3 GB. Exports, and images shown at their own size or larger, still draw from the image itself. |
| 2026-11-10 | minor | M12, #2226 (step 1 of 4): mind maps in the document and engine. `board.mindMap` places a first node with three children on curved branches and `board.mindMapAdd` adds a child or a sibling; the normalize pass lays a touched map out again from its first node (88 between columns, 48 between siblings, stacks centred on their parents). No toolbar entry yet; numbers unchanged. |
| 2026-11-03 | minor | Traditional Chinese (Taiwan) (`zh-Hant`) draft catalog: 1,975 UI strings, 641 command labels and 31 menu wordings, clean-room from the English source; zh-TW, zh-HK, zh-MO and zh-Hant read it, explicit Hans stays Simplified; Noto Sans CJK TC then Taiwan system faces; no bundled font; native review pending. Numbers unchanged |
| 2026-11-03 | patch | M4, #1747: a text layer still carrying the name its characters gave it is renamed as they are typed, so the Layers panel no longer shows the word a layer started with while the canvas shows another (`hello` typed into `hello there` read as `hello` in the panel). A name the author typed is kept, and only the editor follows the characters: commands leave the name alone, so the layer names `data.fill`, component property bindings and templates use as field keys stay put. Tests: `text_behaviour_tests::typing_renames_a_text_layer_that_still_carries_the_name_its_characters_gave_it`, `setting_characters_as_a_command_never_renames_the_layer`. Grade unchanged |
+6 -3
View File
@@ -1,6 +1,6 @@
# FigCraft parity with Figma
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (a typed number and a full stop at the start of a paragraph starts a numbered list at that number, as measured in Figma 2026-11-11; justified Chinese, Japanese and Korean text, written without spaces, spreads each line's room between its characters so its right edge aligns (#1970); joining the two ends of a path broken open by a deleted point closes it again, so its fill and selection outline follow the new segment and its corner radius instead of a straight line across the gap (#1951); glass's docs say which centre splay bends away from and where a shape gets no core; glass bends towards its core, rounded rectangles in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps, measured against the reference tool; bulleted lists use the same bullet at every level of indentation, as the help center says, instead of a white bullet and a square at the deeper levels; typing `1)` and a space at the start of a paragraph starts a numbered list, as `1.` does, both being the help center's creation characters; a link on text is underlined by default, as the help center says, and Underline takes the underline off while the link stays; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); Thai, Lao, Khmer and Burmese lines break between words (#1219); MCP and control calls name their document (#998); shadows of images with transparent pixels follow their alpha (#1289); Arabic draft catalog; truncated text fills its last line before the ellipsis and keeps truncating when resized; auto-height text with max lines (#1375, #1376, #1377), numbers unchanged; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite aLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (an exported layer with a background blur or glass keeps the blurred content behind it within its bounds (#2213); a typed number and a full stop at the start of a paragraph starts a numbered list at that number, as measured in Figma 2026-11-11; justified Chinese, Japanese and Korean text, written without spaces, spreads each line's room between its characters so its right edge aligns (#1970); joining the two ends of a path broken open by a deleted point closes it again, so its fill and selection outline follow the new segment and its corner radius instead of a straight line across the gap (#1951); glass's docs say which centre splay bends away from and where a shape gets no core; glass bends towards its core, rounded rectangles in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps, measured against the reference tool; bulleted lists use the same bullet at every level of indentation, as the help center says, instead of a white bullet and a square at the deeper levels; typing `1)` and a space at the start of a paragraph starts a numbered list, as `1.` does, both being the help center's creation characters; a link on text is underlined by default, as the help center says, and Underline takes the underline off while the link stays; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); Thai, Lao, Khmer and Burmese lines break between words (#1219); MCP and control calls name their document (#998); shadows of images with transparent pixels follow their alpha (#1289); Arabic draft catalog; truncated text fills its last line before the ellipsis and keeps truncating when resized; auto-height text with max lines (#1375, #1376, #1377), numbers unchanged; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suiLine truncated
The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md)
summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is
@@ -102,7 +102,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
| 4 | Shapes & vector | 8 | 99% | 73% | Corner radius and smoothing on boolean groups that contain rectangles; per-point caps and joins on dashed, variable-width and brush strokes; vector QA with real files (behaviour suites from the documentation, corner radius and star ratio handles, corner smoothing on polygons, stars and closed vectors, rounded vector points drawn, also beside curved segments, a cap and join per point, several handles dragged together, cut and paint bucket drags, delete and heal along the curve, a boolean group's look by operation and the miter angle landed) | 1–3 |
| 5 | Paint | 7 | 90% | 71% | Video fills, wide-gamut canvas, HEIC images; plus darker in PDF (behaviour suite from the documentation, pass-through and isolating groups, clipping frames that don't isolate, plus darker, animated GIF fills, TIFF images and embedded image colour profiles landed) | 2–5 |
| 6 | Strokes | 3 | 95% | 75% | Stroke QA with real files (colour styles on strokes, stroke weight and per-side weight variables, brush strokes landed, see 20) | 1–2 |
| 7 | Effects | 4 | 100% | 75% | Glass and progressive blur tuned side by side (behaviour suite from the documentation, inner shadows and background blur on groups, shadows hidden behind translucent layers on the canvas and in every export landed) | 2–5 |
| 7 | Effects | 4 | 100% | 75% | Glass and progressive blur tuned side by side (behaviour suite from the documentation, inner shadows and background blur on groups, shadows hidden behind translucent layers on the canvas and in every export landed; a background blur or glass exported on its own keeps the content behind it within its bounds, measured 2026-11-10, #2213) | 2–5 |
| 8 | Frames, groups, sections, masks | 5 | 97% | 80% | Visual QA with real files (a one-step frame/group convert, and a behaviour suite from the documentation landed: masked-out areas don't take clicks, mask group bounds and exports follow the mask, sections kept out of frames and groups, frames and sections drawn around layers take them, section resizing leaves contents in place, X/Y measured in the frame) | 1–3 |
| 9 | Auto layout & constraints | 9 | 98% | 74% | Behaviour against real Figma files (behaviour suite from the documentation, `fr` tracks like CSS, vertical wrap, auto gaps evenly/around, cell alignment and wrapped-line alignment landed) ([auto-layout-parity](auto-layout-parity.md)) | 3–6 |
| 10 | Text | 8 | 90% | 70% | Spell check, text metrics on real files and in other fonts (text box sizes measured against documents re-saved by other software: all 230 self-sizing boxes in 7 documents come out to the pixel, one font family; behaviour suite with CJK line breaking, Thai, Lao, Khmer and Burmese breaks between words, vertical trim and carets by grapheme cluster landed; lists per paragraph with nesting and list spacing (typing `-`, `*`, `1.` or `1)` and a space starts one), hanging punctuation and lists, decoration style, thickness, offset, skip ink and colour landed) | 3–7 |
@@ -246,7 +246,7 @@ Hours in this narrative are the builders' estimates; the tables above supersede
closed area (faces with holes), point/segment selection, handle mirroring (none/angle/angle and
length) with a Point panel (position, corner radius, mirroring), smooth/sharp points, join and
merge (joining the two ends of a path broken open by a deleted point closes it again, so its
fill follows the new segment, #1951), delete vs delete-and-heal, nudging, one undo step per gesture, eraser (⇧E), shape builder
fill follows the new segment, and a rounded loop left open where a segment joins its ends is cut by arcs at them too, and Delete closes such a loop through that segment, #1951), delete vs delete-and-heal, nudging, one undo step per gesture, eraser (⇧E), shape builder
(⇧M: the regions of overlapping shapes, click or drag to merge, Alt to remove, curves kept),
variable width strokes (width profile presets with previews in the stroke settings, the Variable
width tool ⇧W: drag the path to add a width point, slide points, drag their handles, Delete;
@@ -654,7 +654,10 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
| 2026-11-03 | minor | Traditional Chinese (Taiwan) (`zh-Hant`) draft catalog: 1,975 UI strings, 641 command labels and 31 menu wordings, clean-room from the English source; zh-TW, zh-HK, zh-MO and zh-Hant read it, explicit Hans stays Simplified; Noto Sans CJK TC then Taiwan system faces; no bundled font; native review pending. Numbers unchanged |
| 2026-11-04 | patch | M4, #1970: justified Chinese, Japanese and Korean text, written without spaces between words, aligns its right edge: the room on each line but a paragraph's last goes between its characters, each a word of its own as for line breaking ("Justify aligns the first word in each line of text to the left-most edge, and the last to the right-most edge"), wherever a line could break there, so none opens before a full stop or closing bracket, nor after an opening one. Latin text widens its spaces as before; mixed text widens both. A gap is reordered with the line's runs as a space of no width would be, so one beside a Hebrew word in Chinese text, or beside a Chinese word within that Hebrew, opens beside the word, not inside it. `figcraft_text::breaks::justify_between`, beside `break_between`, says where. Evidence: `tests::justified_ideographs_spread_the_room_between_characters` (fails on main). |
| 2026-11-04 | patch | M8, #1951 (#1506 again): a path broken open by deleting a point (Delete, not Shift+Delete) is filled closed by a straight line across the gap, as an open path's fill is; joining its two ends again (the pen onto the other end, Connect, ⌘J) left the loop without the new segment, so once the points were rounded the fill and the selection outline ran straight across while the stroke followed the segment and its arcs. Joining now closes the loop through the new segment ("Click to close the path."; "You can use the Paint tool to add fills to any closed regions of a vector network."): `ops::connect` puts it into the loop whose segments are the chain between the ends, at the gap and in the loop's own order, so a hole keeps its winding whichever end was clicked first; a loop listed out of walking order is left as it was. Evidence: `vectoredit::tests::a_rounded_chamfer_made_other_ways_fills_along_its_arcs` (fails on main: the loop kept 4 of 5 segments), `a_rounded_chamfer_cut_in_the_editor_fills_along_its_arcs` (Shift+Delete, with and without a region fill), `doc::tests::a_chamfered_rectangles_rounded_fill_is_cut_at_every_point` (fill, region path and stroke agree for every loop order and segment direction of a chamfered rectangle). |
| 2026-11-11 | patch | M4, #1951 (reporter's file, v0.17.0): a corner cut off by a segment drawn first, the corner point then deleted with Delete, leaves the region's loop as the open chain of the other segments, the cutting segment (which joins the chain's two ends) in no loop; rounded, the loop's first point was never cut by an arc (rounding only adds arcs at points the loop arrives at) and the closing edge ran straight, so the fill and the selection outline missed corners the stroke rounded. `VectorNetwork::rounded_smoothed` now walks the segment joining an open loop's ends, when no loop holds it, as the loop's last segment (`closing_segment`). Evidence: `doc::tests::a_rounded_loop_left_open_where_a_segment_joins_its_ends_follows_that_segment` (fails on main: fill 1810.85 vs stroke 1818.58); #1991's tests unchanged. |
| 2026-11-11 | patch | M4, #1951: Delete (and removing segments, and the eraser) closes a region's loop through the segment that joins its ends: a bridge drawn between two points of a ring leaves the region alone (`connect` rightly keeps the ring), and deleting the point between them rotated the loop open, the bridge in no loop, so even an unrounded fill ran straight across a curved bridge. `reopen_loops` now pushes the one segment in no loop that joins an open chain's ends onto the loop's end, keeping its winding, as #1991's connect does; `closing_segment` (now public) walks strictly, ignores segments already in loops and gives up when two qualify. The render fix stays as the fallback for files saved in that state. Evidence: `vectoredit::tests::deleting_the_corner_behind_a_pen_bridge_closes_the_loop_through_the_bridge` (fails before: the loop kept 4 of 5 segments), `ops::tests::deleting_a_point_behind_a_curved_bridge_keeps_the_bridge_in_the_loop`, `doc::tests::an_open_loop_follows_the_segment_joining_its_ends_listed_first_or_walked_backwards`. |
| 2026-11-06 | patch | M3: glass's docs say which centre `splay` bends away from: the centre of the layer's bounds, as the reference tool's, checked side by side on a ring and an L at Splay 0% and 100% (an inner edge bends towards the hole in both). And where a glass shape gets no core: past 4M px on the desktop and 1M on the web (`MAX_SHAPE_PX`) it bends along its edge normals, so a large glass text or vector can bend differently once zoomed into, in a 2× export, or on the web. Docs only. Grade unchanged. |
| 2026-11-10 | patch | M10, #2213: a layer with a background blur or glass exported on its own includes the blurred content behind it within its bounds, as the reference tool's web app does (measured 2026-11-10: the pane's own bounds, opaque where the backdrop is, nothing outside them; layers above it stay out, "contents only" unchanged for layers without those effects); the export reads the page's layers below the exported one as its backdrop |
| 2026-11-02 | minor | M3, #1885: glass bends towards its core, the shape shrunk by `depth` and the bevel, as the reference tool's: round a sharp corner the bend turns smoothly, and a thin part (a capsule, a letter's stroke) bends across itself, checked side by side on a capsule 44 units tall. Rounded rectangles work it out in closed form. A shape too big to keep whole at the zoom bends along its edge normals instead, so no render works out a core over more pixels than a kept shape holds. Tests: `glass::tests::a_sharp_corner_bends_round_towards_the_core`, `glass::tests::a_thin_part_bends_across_itself`, `glass::tests::a_shape_too_big_to_keep_bends_along_its_normals`, `glass_cut_tests::a_cut_through_the_pane_matches_the_whole_render`. Grade unchanged. |
| 2026-11-02 | minor | M3, #1885: glass's light is a line on its 1-unit bevel added to the backdrop, as the reference tool's: fully where the edge faces the light, none below 0.35 of facing it, 0.6 as much on the opposite side, with a faint gradient across the curve. `splay` turns the bend from along the edge's normal towards the way away from the layer's centre and no longer widens the light, as in the reference tool. Checked side by side on synthetic files: a splay of 1 bends the backdrop towards the centre in both. Tests: `glass::tests::the_light_lies_on_the_bevel_and_the_gradient_across_the_curve`, `backdrop_tests::splay_turns_the_bend_towards_the_centre`, `glass_cut_tests::the_light_is_a_line_on_the_bevel`. Grade unchanged. |
| 2026-11-02 | minor | M3, #1885: glass bends the backdrop with the reference tool's thick-lens profile (most a 1-unit bevel in from the edge, falling off as the 3.5th power of the way out of the curve to nothing `depth` further in) and splits its colours over 8 steps, red and blue 0.16 of the bend apart at 100% dispersion. Measured against the reference tool on synthetic files at 2×: the black band a pane pulls to its edge ends within 3 px of the reference tool's, and red and blue part 2.27 px at 50% dispersion and 4.66 px at 100% (2.38 and 4.69 there). Tests: `glass::tests::the_bend_follows_the_thick_lens_profile`, `glass::tests::dispersion_splits_the_channels_along_the_bend`. Grade unchanged. |
+7 -4
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Flatten bakes a vector's point corner radius into its path (#2195); 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 through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Line truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Flatten bakes a vector's point corner radius into its path (#2195); 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 through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Line 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:
@@ -103,6 +103,7 @@ interaction feel not yet compared.
| Text: superscript and subscript | done | `sups` / `subs` use the font's own glyphs where it has the feature and every letter or digit of the run gets one; otherwise the standard glyphs are drawn shrunk and raised or lowered by the font's OS/2 superscript and subscript metrics (0.65 em at 0.35 em up or 0.14 em down without them), and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says ("Figma draws a synthesized version by using the font's standard version of the character, shrinking it, and positioning it accordingly"; mixed glyphs "all update to the synthesized form for consistency"), #1828; tests `text::tests::superscript_native_or_synthesised_and_never_mixed`, `text_behaviour_tests::superscript_and_subscript_are_synthesised_without_glyphs_for_them` |
| Eyedropper (I) | done | picks the colour seen under the pointer (opaque) into the selection's fill and the recent colours, one undo step (`fx_behaviour_tests`); with Cmd/Ctrl+Shift held it makes a colour style or variable of it instead, as the reference app's eyedropper does: a Create from color window asks which, its name (the next free "Color n") and a variable's collection (the file's first, or a new one made with it in the same undo step), and leaves the selection's fills alone (`eyedropper_create_tests`, `sampled_color_tests`) |
| Comment (C), Shift+C to show/hide | done | local only |
| Annotation tool (Shift+T) | done | picks the layer under the pointer, or the selection (or an `id`) with `tool.annotation`, and opens its note in the Inspect tab with the new-note field focused; it works in Developer Mode and in Design mode, and stays selected until V or Escape (inferred). Figma's placement, cursor and whether the tool stays selected are unmeasured (Developer Mode is paywalled in the web app); staying selected until V or Escape, like the Comment tool, is inferred, and there is no toolbar button or icon yet |
| Text on a path (Figma Draw) | done | the Text on a path tool in the Text flyout (`tool.textOnPath`) shows the I-beam over a shape's or vector's path, and a click there puts new text on the path, starting at the click, with the path's effects and its fill when one shows; the path layer is removed, and the path comes back if nothing is typed. Also from the Design panel or `text.onPath`; the path is edited by resizing only |
| Stretch and scatter brushes (Figma Draw) | done | eight presets drawn in code; the Pencil's brush is set by `tool.pencilBrush`, not from the toolbar |
| Image crop handles; Crop image | done | Object ▸ Crop image, the right-click menu and the crop button on an image fill row (`image.crop`) switch the image to Crop where it already shows and enter crop mode: the whole image shows, dimmed outside the layer; the layer's box carries L-shaped corner brackets and edge bars in the selection colour (in place of the square handles), and its edges and corners crop it with the picture held still (as Cmd/Ctrl-resizing does anywhere), the image drags to move and its corners scale it (Shift: freely); Enter, Escape or a click outside finishes (`image_crop_tests`, `crop_image_tests`). Not compared side by side |
@@ -139,9 +140,9 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
| | Count | Notes |
|---|---|---|
| Documented shortcuts (rows) | 136 | each with macOS and Windows/Linux keys |
| Bound as documented | 135 (110 before this suite) | 112 keymap bindings, 15 held keys, gestures and presentation keys, 6 vector edit tool keys |
| Bound as documented | 135 (110 before this suite) | 112 keymap bindings, 16 held keys, gestures and presentation keys, 6 vector edit tool keys |
| Documented shortcuts (rows) | 137 | each with macOS and Windows/Linux keys |
| Bound as documented | 136 (110 before this suite) | 113 keymap bindings, 15 held keys, gestures and presentation keys, 6 vector edit tool keys |
| Bound as documented | 136 (110 before this suite) | 113 keymap bindings, 16 held keys, gestures and presentation keys, 6 vector edit tool keys |
| Bound on other keys (documented conflict) | 1 | Run last plugin: the documentation also gives Ctrl+Alt+P to pixel preview on Windows; the plugin takes Ctrl+Alt+Shift+P there |
| Missing | 0 | moving the rotation origin (Alt+R, `transform.rotationOrigin`) and placing an object with Enter after choosing its tool (`tool.place`) were the last two |
| Documented keys a platform keeps | 2 | Ctrl+Space (Windows/Linux keyboard box selection) switches some CJK input methods; F6 goes to a browser's address bar in the web build |
@@ -189,6 +190,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
| Date | Change | Summary |
|---|---|---|
| 2026-11-09 | minor | Shortcuts: Shift+T picks the new Annotation tool (`tool.annotation {id?}`, Accessibility at Figma, Add an annotation): with a layer selected, or an `id`, it asks the UI for that layer's note at once; in the tool a press on a layer selects it and asks, a press on empty canvas does nothing; the Inspect tab opens with the caret in the new-note field and the engine writes nothing itself; documented shortcuts 137, bound 136 (#1840). Figma's placement, cursor and whether the tool stays selected are unmeasured (Developer Mode is paywalled in the web app); staying selected until V or Escape, like the Comment tool, is inferred, and there is no toolbar button or icon yet |
| 2026-11-11 | patch | M5, gap 4: marquee selection as measured in Figma's web app (Select layers and objects, Selection marquee): a partly covered top-level frame or section gives the layers in it the marquee touches (it was selected only when enclosed, so nothing came), an empty frame is selected on touch, a rotated shape is touched by its outline and not its box, a drag from inside a frame selects that frame only when the marquee also takes another top-level frame (then whole), and also takes the top-level layers it touches outside it, and selecting a frame whole keeps the marquee at the top level (a partly covered frame then comes whole, not its layers). Flipped: `marquee_selects_what_it_touches`, `cmd_marquee_selects_nested_layers`, `a_box_selects_like_a_marquee`. Tests: `marquee_behaviour_tests`. Counts unchanged |
| 2026-11-11 | patch | #2195: Flatten on a vector whose points have a corner radius bakes the rounding into the path, as measured in Figma web: each rounded point becomes the arc's end points joined by curves (a 3-point V with radius 60 on the apex gives 4 points), the layer stays a vector, every radius reads 0 afterwards and the box shrinks to the real geometry (245 to 205.09 high). Shapes' own radii were already baked. Numbers unchanged. |
| 2026-11-08 | minor | Vector edit mode, #1875, measured in Figma (web, real keys): Copy with segments selected takes only those (with just a point selected, the segments touching it) and a paste outside edit mode is a new vector layer in place; Cut removes them, the path splitting into subpaths in the same layer with edit mode staying on, and a paste outside gives a new layer with the cut segments; Paste while editing a vector adds the clipboard's segments, or a copied layer's outline, to the same network in place, selecting the pasted points, with no new layer and the bounds growing. FigCraft copied the whole layer, Cut left nothing to paste and Paste did nothing. `edit.copy`, `edit.cut` and `edit.paste` act so with no `ids`/`parent` given (Ctrl+X is the real Cut again; Shift+Delete still heals); the copy carries the layer's look (fills, strokes, effects, opacity, blend; the width profile only when every segment is copied); layers with no outline are skipped. Tests: `vector_clipboard_tests` |
@@ -208,6 +210,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
| 2026-11-06 | patch | M7, #2032: closing a tab whose file has unsaved changes, by its X, a middle click, File ▸ Close tab or its shortcut, only showed a toast ("save first, or close again with Alt to discard") and left the tab open, so closing seemed not to work. It now asks the unsaved-changes question the window asks (`windows::TabClose`): Don't Save closes the tab, Cancel or Escape keeps it, Save saves the file and then closes it (a file without a path, or saved as .fig, asks where with Save as first and stays open if that is cancelled); Alt+close still drops the changes at once, and `file.close`'s `dialog.unsaved` request asks too. The three toasts left the catalogs. Tests: `titlebar_tests::closing_a_tab_with_unsaved_changes_asks`, `titlebar_tests::save_in_the_tab_question_saves_then_closes`. Reordering tabs by dragging, the issue's other half, landed on 2026-10-19 (`dragging_a_tab_along_the_bar_reorders_it_without_detaching`) |
| 2026-11-05 | minor | M4, #1830: a paragraph's base direction can be forced (`TextDirection`: Auto, Ltr, Rtl on `ParaList`): Text ▸ Text direction ▸ Auto / Left to right / Right to left (`text.directionAuto` / `Ltr` / `Rtl`) and `text.direction {direction, ids?}` set the edited paragraphs while editing, else the layer's; `node.set textDirection`; the Type settings control once a right-to-left script is in the text; inspect `textDirection` / `lineDirections`; CSS `direction`; `.fig` lines' `sourceDirectionality` in and out. Layout gives each paragraph its base level (`Bidi::of` runs the algorithm per paragraph, as it starts over at each). Before, direction came from the letters only ("controls are available to override text direction per paragraph", Add right-to-left text). Tests: `text_behaviour_tests::text_direction_is_forced_per_paragraph`, `fig::text_direction_tests::a_lines_source_directionality_is_its_paragraphs_direction_both_ways`. |
| 2026-11-02 | minor | M1, #1836: pick select. Tab while the keyboard selection box shows, or Alt/Ctrl+Space again, puts a pink outline on the first selected layer (the page's top layer with nothing selected); Tab and Shift+Tab move it between sibling layers as the Layers panel lists them, Enter into a frame's top child, Shift+Enter out to the parent (never the page), Space selects the layer under it, Escape or a press on the canvas ends it; the chord again while the pick cursor shows brings the box back (Accessibility at Figma). The control channel's `ui.key` sends the box's and the pick cursor's keys to them too. One documented row more (135, 134 bound); four gesture rows. Tests: `layers_keyboard_tests::tab_switches_the_selection_box_to_pick_select`. |
| 2026-11-10 | patch | M10, #2227: the Design panel's section header buttons (Appearance, Typography, Fill, Stroke, Effects) draw their icons at the size of the panel's other 24 pt icon buttons, 16 pt (the icons' native size, as a fill row's eye), not 12 pt; `icon_button_at` goes from 14 to 16 pt with them; the Close and Swap buttons of the assets and prototype panels and the Design panel's Swap orientation and quick-action buttons share the inset. Test: `header_icon_size_tests`. Numbers unchanged. |
| 2026-11-05 | patch | M1, #2003: the Design panel's boolean operation menu leads Union, Subtract, Intersect and Exclude with original icons drawn in code (two offset squares, the result filled at reduced alpha). Numbers unchanged. |
| 2026-11-02 | minor | M8, #1875: a fill per region. With regions of a vector selected in edit mode (a press inside one with the Move tool, `vector.edit.select {regions}`), the Fill section lists the regions' own fills (the layer's for a region that has none; mixed content when they differ) and its rows and the colour picker edit them: `fill.set`, `fill.add`, `fill.remove`, `fill.move` and `fill.paste` on the edited layer go to the selected regions' stacks (`Region.fills`, what the renderer already draws), the layer's fills and fill style untouched; `regions: [index, ...]` names them on any vector layer (Edit vector layers). Tests: `regions_tests::the_fill_commands_edit_the_selected_regions`. Numbers unchanged. |
| 2026-11-05 | patch | M1, #1758: a mouse's side buttons (4 and 5) pan the canvas as the middle button does, the pan ending on release over the canvas or off it; the grab cursor shows while they're held. |