From 0e6fcf9bf7dfea9ceff487b0b2682a0960924bb6 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 31 Oct 2026 12:03:45 +0100 Subject: [PATCH 01/18] Make undo while drawing with the pen remove only the last anchor Fixes #1745 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/canvas/tools.rs | 2 + crates/engine/src/canvas/tools/pen.rs | 37 ++++++++++++++ crates/engine/src/lib.rs | 2 + crates/engine/src/pen_undo_tests.rs | 70 +++++++++++++++++++++++++++ crates/engine/src/session/undo.rs | 8 +++ 5 files changed, 119 insertions(+) create mode 100644 crates/engine/src/pen_undo_tests.rs diff --git a/crates/engine/src/canvas/tools.rs b/crates/engine/src/canvas/tools.rs index c68276fd3..591096b36 100644 --- a/crates/engine/src/canvas/tools.rs +++ b/crates/engine/src/canvas/tools.rs @@ -544,6 +544,8 @@ pub struct PenPath { pub closed: bool, /// The path ends connected to this anchor of its own (a vector network). pub join: Option, + /// Anchors taken back by undo, the last one first to come back on redo. + pub undone: Vec<(Point, Vec2, Vec2)>, } #[derive(Clone, Debug, Default)] diff --git a/crates/engine/src/canvas/tools/pen.rs b/crates/engine/src/canvas/tools/pen.rs index d519bc580..b726c3232 100644 --- a/crates/engine/src/canvas/tools/pen.rs +++ b/crates/engine/src/canvas/tools/pen.rs @@ -118,10 +118,47 @@ impl Session { p = at_45_degrees(*last, p); } pen.points.push((p, Vec2::ZERO, Vec2::ZERO)); + pen.undone.clear(); self.tool.gesture = Gesture::PenDrag { anchor: p }; self.pen_update() } + /// Undo while the pen draws (#1745): its last anchor goes (before two anchors, with the layer + /// they made; with the only one, the whole path). Returns whether there was a path. + pub(crate) fn pen_undo(&mut self) -> Result { + let Some(pen) = self.tool.pen.as_mut() else { return Ok(false) }; + let Some(last) = pen.points.pop() else { + self.tool.pen = None; + return Ok(false); + }; + pen.undone.push(last); + let drop_layer = pen.points.len() < 2 && pen.id.take().is_some(); + let empty = pen.points.is_empty(); + self.tool.gesture = Gesture::None; + self.tool.overlay.rubber = None; + self.tool.overlay.rubber_handle = None; + if drop_layer { + self.cancel_interaction()?; + } + if empty { + self.pen_finish()?; + return Ok(true); + } + self.pen_update()?; + Ok(true) + } + + /// Redo while the pen draws: the last anchor undo took back returns. Returns whether there was + /// one. + pub(crate) fn pen_redo(&mut self) -> Result { + let Some(pen) = self.tool.pen.as_mut() else { return Ok(false) }; + let Some(point) = pen.undone.pop() else { return Ok(false) }; + pen.points.push(point); + self.tool.gesture = Gesture::None; + self.pen_update()?; + Ok(true) + } + /// Rebuild the pen's vector node from its anchors. pub(super) fn pen_update(&mut self) -> Result<()> { let Some(pen) = self.tool.pen.clone() else { return Ok(()) }; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1c0b7e39a..a1ceeeb2f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -457,6 +457,8 @@ mod pen_end_tests; #[cfg(test)] mod pen_overlay_tests; #[cfg(test)] +mod pen_undo_tests; +#[cfg(test)] mod pen_vector_edit_tests; #[cfg(test)] mod penpot_export_tests; diff --git a/crates/engine/src/pen_undo_tests.rs b/crates/engine/src/pen_undo_tests.rs new file mode 100644 index 000000000..2314fc66a --- /dev/null +++ b/crates/engine/src/pen_undo_tests.rs @@ -0,0 +1,70 @@ +//! Undo while the pen draws (#1745): each undo takes back the path's last anchor, not the whole +//! path (nor the step before it); redo puts the anchors back. + +use crate::Session; +use crate::tools::{PointerEvent, PointerKind, ToolId}; +use figcraft_doc::NodeKind; +use serde_json::json; + +fn click(s: &mut Session, x: f64, y: f64) { + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::at(kind, x, y, 1.0)).unwrap(); + } +} + +fn layers(s: &Session) -> usize { + s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len() +} + +fn pen_points(s: &Session) -> Option { + s.tool.pen.as_ref().map(|p| p.points.len()) +} + +/// (vertices, segments) of the pen's vector, if it has one. +fn vector(s: &Session) -> Option<(usize, usize)> { + let id = s.tool.pen.as_ref().and_then(|p| p.id)?; + match &s.doc().unwrap().node(id)?.kind { + NodeKind::Vector { network } => Some((network.vertices.len(), network.segments.len())), + _ => None, + } +} + +fn undo(s: &mut Session) -> bool { + s.execute("edit.undo", &json!({})).unwrap()["undone"] == true +} + +fn redo(s: &mut Session) { + s.execute("edit.redo", &json!({})).unwrap(); +} + +#[test] +fn undo_while_drawing_takes_back_one_anchor() { + let mut s = Session::new(); + s.execute("shape.rectangle", &json!({"x": 400, "y": 400, "width": 50, "height": 50})).unwrap(); + assert_eq!(layers(&s), 1); + s.set_tool(ToolId::Pen); + click(&mut s, 100.0, 100.0); + click(&mut s, 200.0, 100.0); + click(&mut s, 200.0, 200.0); + assert_eq!((pen_points(&s), vector(&s), layers(&s)), (Some(3), Some((3, 2)), 2)); + + assert!(undo(&mut s)); + assert_eq!((pen_points(&s), vector(&s), layers(&s)), (Some(2), Some((2, 1)), 2), "only the last anchor goes"); + assert!(undo(&mut s)); + assert_eq!((pen_points(&s), vector(&s), layers(&s)), (Some(1), None, 1), "one anchor, no layer yet"); + + // Redo puts the anchors back in order. + redo(&mut s); + assert_eq!((pen_points(&s), vector(&s), layers(&s)), (Some(2), Some((2, 1)), 2)); + redo(&mut s); + assert_eq!((pen_points(&s), vector(&s), layers(&s)), (Some(3), Some((3, 2)), 2)); + let pts: Vec<_> = s.tool.pen.as_ref().unwrap().points.iter().map(|(p, _, _)| (p.x, p.y)).collect(); + assert_eq!(pts, vec![(100.0, 100.0), (200.0, 100.0), (200.0, 200.0)]); + + for _ in 0..3 { + assert!(undo(&mut s)); + } + assert_eq!((pen_points(&s), layers(&s)), (None, 1), "the path is gone, the rectangle stays"); + assert!(undo(&mut s)); + assert_eq!(layers(&s), 0, "the next undo takes the rectangle"); +} diff --git a/crates/engine/src/session/undo.rs b/crates/engine/src/session/undo.rs index fdf33b91f..14fdfe5b9 100644 --- a/crates/engine/src/session/undo.rs +++ b/crates/engine/src/session/undo.rs @@ -114,6 +114,10 @@ impl Session { pub fn undo(&mut self) -> Result { textpreview::end(self); + // While the pen draws, undo works on the anchors of its path (#1745). + if self.ds().is_ok_and(|d| d.preview.is_none()) && self.pen_undo()? { + return Ok(true); + } let live = self.ds().is_ok_and(|d| self.live_doc(d.uid)); let ds = self.ds_mut()?; if ds.preview.is_some() { @@ -152,6 +156,10 @@ impl Session { pub fn redo(&mut self) -> Result { textpreview::end(self); + // While the pen draws, redo works on the anchors of its path (#1745). + if self.ds().is_ok_and(|d| d.preview.is_none()) && self.pen_redo()? { + return Ok(true); + } let live = self.ds().is_ok_and(|d| self.live_doc(d.uid)); let ds = self.ds_mut()?; if ds.preview.is_some() { From 16d97cf8e96ab81868e192e1e78930f47d01c67c Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sun, 1 Nov 2026 00:33:58 -0400 Subject: [PATCH 02/18] M6: SVG import: a stretched image stays stretched, and a dash offset on a closed outline is kept Found while testing crates/svg import (no existing issue), each with a test that fails before and passes after. An took ImageScaleMode::Fill, which keeps the picture aspect and crops; a box of a different aspect now takes a whole-picture Crop, which the renderer draws as a stretch (SVG 2 section 8.6). A stroke-dashoffset equal to half the first dash was skipped on every shape because an open line starts half a dash in by itself; a closed outline runs its pattern from its start, so the exemption now applies to open outlines only (SVG 2 section 13.5.6). Co-Authored-By: Claude Fable 5.1 --- crates/svg/src/import.rs | 72 +++++++++++++++++++----- crates/svg/src/import/structure_tests.rs | 39 +++++++++++++ crates/svg/src/import/text.rs | 2 +- 3 files changed, 99 insertions(+), 14 deletions(-) diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 71c6c3b05..ed390ca4a 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -374,8 +374,11 @@ fn path(cx: &mut Ctx, p: &usvg::Path, parent: NodeId, base: Affine) { let local = to_bez(p.data()); let (fill, stroke) = (p.fill(), p.stroke()); let shaped = shape::recognize(&local, stroke.is_none()).and_then(|prim| primitive(prim, world)); - let (kind, transform, w, h, radius) = match shaped { - Some(s) => s, + let (kind, transform, w, h, radius, closed_outline) = match shaped { + Some((kind, transform, w, h, radius)) => { + let closed_outline = !matches!(kind, NodeKind::Line); + (kind, transform, w, h, radius, closed_outline) + } None => { let mut path = local; path.apply_affine(world); @@ -406,7 +409,10 @@ fn path(cx: &mut Ctx, p: &usvg::Path, parent: NodeId, base: Affine) { let fill_only = Node::new(cx.doc.alloc_id(), vector(&closed, true), format!("{} fill", layer_name(p.id(), "Vector")), bb.width(), bb.height()); let stroke_only = Node::new(cx.doc.alloc_id(), vector(&snapped, false), layer_name(p.id(), "Vector"), bb.width(), bb.height()); - let mut layers = [paint_path(cx, p, fill_only, at, world, true, false), paint_path(cx, p, stroke_only, at, world, false, true)]; + let mut layers = [ + paint_path(cx, p, fill_only, at, world, Paints { fill: true, stroke: false, closed: true }), + paint_path(cx, p, stroke_only, at, world, Paints { fill: false, stroke: true, closed: every_subpath_closed(&snapped) }), + ]; if p.paint_order() == usvg::PaintOrder::StrokeAndFill { layers.reverse(); } @@ -415,7 +421,8 @@ fn path(cx: &mut Ctx, p: &usvg::Path, parent: NodeId, base: Affine) { } return; } - (vector(if fill.is_some() { &closed } else { &path }, fill.is_some()), at, bb.width(), bb.height(), 0.0) + let outline = if fill.is_some() { &closed } else { &path }; + (vector(outline, fill.is_some()), at, bb.width(), bb.height(), 0.0, every_subpath_closed(outline)) } }; let fallback = match kind { @@ -426,19 +433,28 @@ fn path(cx: &mut Ctx, p: &usvg::Path, parent: NodeId, base: Affine) { }; let mut n = Node::new(cx.doc.alloc_id(), kind, layer_name(p.id(), fallback), w, h); n.corners.radii = [radius; 4]; - let n = paint_path(cx, p, n, transform, world, true, true); + let n = paint_path(cx, p, n, transform, world, Paints { fill: true, stroke: true, closed: closed_outline }); cx.add(n, parent); } +/// Which of a path's paints a layer takes, and whether its stroked outline closes on itself (see +/// [`stroke_props`]). +#[derive(Clone, Copy)] +struct Paints { + fill: bool, + stroke: bool, + closed: bool, +} + /// Layer `n` of path `p` placed at `transform`, with the path's fill and / or stroke. -fn paint_path(cx: &mut Ctx, p: &usvg::Path, mut n: Node, transform: Affine, world: Affine, with_fill: bool, with_stroke: bool) -> Node { +fn paint_path(cx: &mut Ctx, p: &usvg::Path, mut n: Node, transform: Affine, world: Affine, paints: Paints) -> Node { n.transform = transform; let target = Target { to_node: transform.inverse() * world, size: (n.width, n.height) }; - n.fills = p.fill().filter(|_| with_fill).and_then(|f| paint::paint(cx, f.paint(), f.opacity().get(), target)).into_iter().collect(); + n.fills = p.fill().filter(|_| paints.fill).and_then(|f| paint::paint(cx, f.paint(), f.opacity().get(), target)).into_iter().collect(); n.strokes.clear(); - if let Some(s) = p.stroke().filter(|_| with_stroke) { + if let Some(s) = p.stroke().filter(|_| paints.stroke) { n.strokes = paint::paint(cx, s.paint(), s.opacity().get(), target).into_iter().collect(); - n.stroke = stroke_props(s, length_scale(world)); + n.stroke = stroke_props(s, length_scale(world), paints.closed); // A stroke painted under the fill shows only its outer half. if p.paint_order() == usvg::PaintOrder::StrokeAndFill && !n.fills.is_empty() && figcraft_doc::geometry::is_closed(&n) { n.stroke.align = StrokeAlign::Outside; @@ -521,7 +537,27 @@ fn primitive(prim: Prim, world: Affine) -> Option<(NodeKind, Affine, f64, f64, f } } -pub(crate) fn stroke_props(s: &usvg::Stroke, k: f64) -> figcraft_doc::StrokeProps { +/// Whether every subpath of `path` closes on itself (and a dash pattern on it runs from its start, +/// as on any closed outline; an open one starts half a dash in). +fn every_subpath_closed(path: &BezPath) -> bool { + use figcraft_geom::kurbo::PathEl; + let els = path.elements(); + let mut closed = true; + for e in els { + match e { + PathEl::MoveTo(_) if !closed => return false, + PathEl::ClosePath => closed = true, + _ => closed = false, + } + } + !els.is_empty() && closed +} + +/// The stroke of `s` on a layer whose lengths `world` scales by `k`; `closed` says whether its +/// outline closes on itself: a closed outline runs a dash pattern from its start, so an offset into +/// the pattern rotates it, while an open line starts half a dash in by itself (and export writes +/// that offset), so that one is already there. +pub(crate) fn stroke_props(s: &usvg::Stroke, k: f64, closed: bool) -> figcraft_doc::StrokeProps { let cap = match s.linecap() { usvg::LineCap::Round => StrokeCap::Round, usvg::LineCap::Square => StrokeCap::Square, @@ -530,8 +566,8 @@ pub(crate) fn stroke_props(s: &usvg::Stroke, k: f64) -> figcraft_doc::StrokeProp let mut dashes: Vec = s.dasharray().unwrap_or(&[]).iter().map(|v| f64::from(*v) * k).collect(); if !dashes.is_empty() { let offset = f64::from(s.dashoffset()) * k; - // Half a dash in is how a line starts by itself (and how export writes one). - if dashes.first().is_none_or(|d| (d / 2.0 - offset).abs() > 1e-3) { + let half_dash_in = !closed && dashes.first().is_some_and(|d| (d / 2.0 - offset).abs() <= 1e-3); + if !half_dash_in { dashes = dash_offset(&dashes, offset, cap == StrokeCap::None).unwrap_or(dashes); } } @@ -608,7 +644,17 @@ fn image(cx: &mut Ctx, img: &usvg::Image, id: &str, parent: NodeId, base: Affine let hash = add_image(cx.doc, bytes, iw, ih); let mut n = Node::new(cx.doc.alloc_id(), NodeKind::Rectangle, layer_name(id, "Image"), w, h); n.transform = transform; - n.fills = vec![Paint::image(hash, ImageScaleMode::Fill)]; + let mut fill = Paint::image(hash, ImageScaleMode::Fill); + // usvg sizes the box to the picture (`meet` and `slice` are in the transform), so a box of + // another shape is `preserveAspectRatio="none"`: the picture stretched edge to edge. A Fill + // would keep its shape and crop it; a crop of the whole picture stretches it. + if (w * ih - h * iw).abs() > 1e-6 * (w * ih).max(h * iw) + && let figcraft_color::PaintKind::Image(ip) = &mut fill.kind + { + ip.scale_mode = ImageScaleMode::Crop; + ip.crop = Some(Affine::IDENTITY.as_coeffs()); + } + n.fills = vec![fill]; cx.add(n, parent); } diff --git a/crates/svg/src/import/structure_tests.rs b/crates/svg/src/import/structure_tests.rs index adeedb005..ee76c2238 100644 --- a/crates/svg/src/import/structure_tests.rs +++ b/crates/svg/src/import/structure_tests.rs @@ -129,6 +129,45 @@ fn strokes_scale_with_their_layer() { assert_eq!((l.width, l.stroke.weight, l.stroke.dashes.clone()), (30.0, 6.0, vec![12.0, 6.0])); } +/// `stroke-dashoffset` is how far into the pattern the stroke starts. A closed outline runs the +/// pattern from its start, so the offset rotates the pattern; an open line starts half a dash in by +/// itself (see `figcraft_pathops::width::line_dashes`), so an offset of half the first dash is +/// already there and only that one is left out. +#[test] +fn a_closed_outline_keeps_a_dash_offset_of_half_a_dash() { + let (d, f) = import( + r##""##, + ); + // Two in: a dash of 2 is left, then the gap, then the pattern as it is (`dash_offset`). + let rotated = vec![2.0, 2.0, 2.0, 0.0]; + assert_eq!(node(&d, f, "r").stroke.dashes, rotated, "rectangle"); + assert_eq!(node(&d, f, "p").stroke.dashes, rotated, "closed path"); + assert_eq!(node(&d, f, "c").stroke.dashes, rotated, "circle"); + assert_eq!(node(&d, f, "l").stroke.dashes, vec![4.0, 2.0], "an open line starts half a dash in anyway"); +} + +/// `preserveAspectRatio="none"` stretches a picture into its box edge to edge. usvg sizes the +/// box to the picture for `meet` and `slice` (the fit is in the transform), so a box of another +/// shape is a stretch: a Fill, which keeps the picture's shape and crops it, would show the +/// middle of the picture instead. +#[test] +fn an_image_stretched_into_its_box_stays_stretched() { + use figcraft_color::{ImageScaleMode, PaintKind}; + let (d, f) = + import(&format!(r##""##)); + let n = node(&d, f, "pic"); + assert_eq!((n.width, n.height), (40.0, 40.0)); + let Some(PaintKind::Image(ip)) = n.fills.first().map(|p| &p.kind) else { panic!("{:?}", n.fills) }; + // The 4 × 2 picture's pixels map onto the whole 40 × 40 box: 10 across, 20 down. + let m = figcraft_render::paint::image_transform(ip, 4.0, 2.0, n.width, n.height); + assert_eq!((m.sx, m.ky, m.kx, m.sy, m.tx, m.ty), (10.0, 0.0, 0.0, 20.0, 0.0, 0.0), "{ip:?}"); + // With the aspect kept (the default) the box is the picture's own shape and a Fill is exact. + let (d, f) = import(&format!(r##""##)); + let n = node(&d, f, "pic"); + assert_eq!((n.width, n.height), (40.0, 20.0)); + assert!(matches!(n.fills.first().map(|p| &p.kind), Some(PaintKind::Image(i)) if i.scale_mode == ImageScaleMode::Fill), "{:?}", n.fills); +} + #[test] fn linked_files_are_never_read() { let dir = std::env::temp_dir().join("figcraft-svg-linked"); diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index b651f859b..cee54bbd8 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -340,7 +340,7 @@ fn add_layer(cx: &mut Ctx, layer: TextLayer<'_, '_, '_>) { n.fills = base_fill; if let Some(st) = spans.first().and_then(|sp| sp.stroke()) { n.strokes = paint(cx, st.paint(), st.opacity().get(), target).into_iter().collect(); - n.stroke = super::stroke_props(st, s); + n.stroke = super::stroke_props(st, s, true); } cx.add(n, parent); } From 8bc405edbccc73ed07f9373bf1c173a7e9b22bee Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sun, 1 Nov 2026 12:16:33 -0500 Subject: [PATCH 03/18] SVG import: the dash offset fix alone, with open-path, mixed-subpath and text tests The stretched-image fix moved to its own PR. Co-Authored-By: Claude Fable 5.1 --- crates/svg/src/import.rs | 12 +------ crates/svg/src/import/structure_tests.rs | 41 +++++++++++++----------- 2 files changed, 23 insertions(+), 30 deletions(-) diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index ed390ca4a..e154ab1c9 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -644,17 +644,7 @@ fn image(cx: &mut Ctx, img: &usvg::Image, id: &str, parent: NodeId, base: Affine let hash = add_image(cx.doc, bytes, iw, ih); let mut n = Node::new(cx.doc.alloc_id(), NodeKind::Rectangle, layer_name(id, "Image"), w, h); n.transform = transform; - let mut fill = Paint::image(hash, ImageScaleMode::Fill); - // usvg sizes the box to the picture (`meet` and `slice` are in the transform), so a box of - // another shape is `preserveAspectRatio="none"`: the picture stretched edge to edge. A Fill - // would keep its shape and crop it; a crop of the whole picture stretches it. - if (w * ih - h * iw).abs() > 1e-6 * (w * ih).max(h * iw) - && let figcraft_color::PaintKind::Image(ip) = &mut fill.kind - { - ip.scale_mode = ImageScaleMode::Crop; - ip.crop = Some(Affine::IDENTITY.as_coeffs()); - } - n.fills = vec![fill]; + n.fills = vec![Paint::image(hash, ImageScaleMode::Fill)]; cx.add(n, parent); } diff --git a/crates/svg/src/import/structure_tests.rs b/crates/svg/src/import/structure_tests.rs index ee76c2238..500675fe4 100644 --- a/crates/svg/src/import/structure_tests.rs +++ b/crates/svg/src/import/structure_tests.rs @@ -146,26 +146,29 @@ fn a_closed_outline_keeps_a_dash_offset_of_half_a_dash() { assert_eq!(node(&d, f, "l").stroke.dashes, vec![4.0, 2.0], "an open line starts half a dash in anyway"); } -/// `preserveAspectRatio="none"` stretches a picture into its box edge to edge. usvg sizes the -/// box to the picture for `meet` and `slice` (the fit is in the transform), so a box of another -/// shape is a stretch: a Fill, which keeps the picture's shape and crops it, would show the -/// middle of the picture instead. +/// An open path is a line in this respect: half a dash in is where it starts anyway, so that +/// offset is left out, and any other is applied; an outline with an open subpath among closed +/// ones is open. #[test] -fn an_image_stretched_into_its_box_stays_stretched() { - use figcraft_color::{ImageScaleMode, PaintKind}; - let (d, f) = - import(&format!(r##""##)); - let n = node(&d, f, "pic"); - assert_eq!((n.width, n.height), (40.0, 40.0)); - let Some(PaintKind::Image(ip)) = n.fills.first().map(|p| &p.kind) else { panic!("{:?}", n.fills) }; - // The 4 × 2 picture's pixels map onto the whole 40 × 40 box: 10 across, 20 down. - let m = figcraft_render::paint::image_transform(ip, 4.0, 2.0, n.width, n.height); - assert_eq!((m.sx, m.ky, m.kx, m.sy, m.tx, m.ty), (10.0, 0.0, 0.0, 20.0, 0.0, 0.0), "{ip:?}"); - // With the aspect kept (the default) the box is the picture's own shape and a Fill is exact. - let (d, f) = import(&format!(r##""##)); - let n = node(&d, f, "pic"); - assert_eq!((n.width, n.height), (40.0, 20.0)); - assert!(matches!(n.fills.first().map(|p| &p.kind), Some(PaintKind::Image(i)) if i.scale_mode == ImageScaleMode::Fill), "{:?}", n.fills); +fn an_open_path_starts_half_a_dash_in_and_takes_other_offsets() { + let (d, f) = import( + r##""##, + ); + assert_eq!(node(&d, f, "half").stroke.dashes, vec![4.0, 2.0], "half a dash in is where an open path starts"); + assert_eq!(node(&d, f, "one").stroke.dashes, vec![3.0, 2.0, 1.0, 0.0], "one in: three of the first dash are left"); + assert_eq!(node(&d, f, "mixed").stroke.dashes, vec![4.0, 2.0], "an open subpath makes the outline open"); +} + +/// A text stroke runs along glyph contours, which close, so its offset is applied as on a shape. +#[test] +fn a_text_stroke_keeps_a_dash_offset_of_half_a_dash() { + if figcraft_text::fontdb::find_face("Inter", "Regular").is_none() { + return; + } + let (d, f) = import( + r##"T"##, + ); + assert_eq!(node(&d, f, "t").stroke.dashes, vec![2.0, 2.0, 2.0, 0.0]); } #[test] From 8fb53baa64c73aa3721e636e944a1e9b5db67da9 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Mon, 2 Nov 2026 02:02:35 -0500 Subject: [PATCH 04/18] SVG import: a filled and stroked path with an open subpath is tested through its split Co-Authored-By: Claude Fable 5.1 --- crates/svg/src/import/structure_tests.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/svg/src/import/structure_tests.rs b/crates/svg/src/import/structure_tests.rs index a44590470..fbc9976ae 100644 --- a/crates/svg/src/import/structure_tests.rs +++ b/crates/svg/src/import/structure_tests.rs @@ -159,6 +159,27 @@ fn an_open_path_starts_half_a_dash_in_and_takes_other_offsets() { assert_eq!(node(&d, f, "mixed").stroke.dashes, vec![4.0, 2.0], "an open subpath makes the outline open"); } +/// A filled and stroked path with an open subpath becomes a fill layer and a stroke layer: the +/// stroke layer is open, so it starts half a dash in like any open path and that offset is left +/// out; a filled and stroked path that closes stays one layer whose offset is applied. +#[test] +fn a_filled_and_stroked_open_path_splits_and_its_stroke_layer_is_open() { + let (d, f) = import( + r##""##, + ); + for id in ["both", "bothmixed"] { + let stroke_layer = node(&d, f, id); + assert!(stroke_layer.fills.is_empty() && !stroke_layer.strokes.is_empty(), "{id}: the stroke layer carries the stroke alone"); + assert_eq!(stroke_layer.stroke.dashes, vec![4.0, 2.0], "{id}: an open stroke layer starts half a dash in by itself"); + let fill_layer = node(&d, f, &format!("{id} fill")); + assert!(!fill_layer.fills.is_empty() && fill_layer.strokes.is_empty(), "{id}: the fill layer carries the fill alone"); + } + let one = node(&d, f, "bothclosed"); + assert!(!one.fills.is_empty() && !one.strokes.is_empty(), "a closed path keeps fill and stroke on one layer"); + assert_eq!(one.stroke.dashes, vec![2.0, 2.0, 2.0, 0.0], "and its offset rotates the pattern"); + assert!(d.subtree(f).into_iter().filter_map(|i| d.node(i)).all(|n| n.name != "bothclosed fill"), "no split for a closed path"); +} + /// A text stroke runs along glyph contours, which close, so its offset is applied as on a shape. #[test] fn a_text_stroke_keeps_a_dash_offset_of_half_a_dash() { From 37803cb1c80902f3e0a6623de8eb9daddce6a48b Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 04:24:14 +0000 Subject: [PATCH 05/18] perf(format): index the lookups that made style-heavy files quadratic REST import searched every style imported so far for each layer's style reference; .fig export compared each text span's style with every distinct style before it; Penpot token-set ordering searched the whole order array for every comparison of the sort. Each now looks up a set, a map or a position table built once. --- crates/format/src/fig/export/shape.rs | 18 +++++++++++++----- crates/format/src/fig/export_tests.rs | 16 ++++++++++++++++ crates/format/src/penpot.rs | 5 ++++- crates/format/src/rest.rs | 8 +++++++- .../format/src/rest/import_regression_tests.rs | 18 ++++++++++++++++++ 5 files changed, 58 insertions(+), 7 deletions(-) diff --git a/crates/format/src/fig/export/shape.rs b/crates/format/src/fig/export/shape.rs index e4e2cae6b..58309784f 100644 --- a/crates/format/src/fig/export/shape.rs +++ b/crates/format/src/fig/export/shape.rs @@ -56,16 +56,24 @@ impl<'d> Exporter<'d> { } let spans = td.spans(); let mut table: Vec<&TextStyle> = Vec::new(); + // Each distinct style once, found by its serialized form rather than by comparing it + // with every earlier one (quadratic in the number of distinct styles). + let mut index: std::collections::HashMap = std::collections::HashMap::new(); let mut span_ids = Vec::with_capacity(spans.len()); for (_, _, st) in &spans { span_ids.push(if **st == td.style { 0 } else { - let i = table.iter().position(|t| *t == *st).unwrap_or_else(|| { - table.push(st); - table.len() - 1 - }); - i as u32 + 1 + match serde_json::to_string(st) { + Ok(key) => *index.entry(key).or_insert_with(|| { + table.push(st); + table.len() as u32 + }), + Err(_) => { + table.push(st); + table.len() as u32 + } + } }); } let mut chars = String::new(); diff --git a/crates/format/src/fig/export_tests.rs b/crates/format/src/fig/export_tests.rs index b1b31262a..f6951cdcb 100644 --- a/crates/format/src/fig/export_tests.rs +++ b/crates/format/src/fig/export_tests.rs @@ -1611,3 +1611,19 @@ fn a_whole_ellipse_starting_anywhere_comes_back_whole() { } } } + +#[test] +fn text_with_thousands_of_distinct_styles_exports_in_linear_time() { + // The style table used to be searched end to end for every span (quadratic in distinct styles). + let mut doc = Document::new("styles"); + let p = doc.first_page().unwrap(); + let mut td = TextData::new("x".repeat(20_000)); + td.runs = (0..20_000) + .map(|i| StyleRun { start: i, end: i + 1, style: TextStyle { font_size: 8.0 + i as f64 / 100.0, ..TextStyle::default() } }) + .collect(); + doc.create(p, NodeKind::Text(td), "T", 0.0, 0.0, 100.0, 100.0).unwrap(); + let start = std::time::Instant::now(); + let (bytes, _) = export(&doc, None).unwrap(); + assert!(!bytes.is_empty()); + assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed()); +} diff --git a/crates/format/src/penpot.rs b/crates/format/src/penpot.rs index 6cd8fe53c..7ffd208d8 100644 --- a/crates/format/src/penpot.rs +++ b/crates/format/src/penpot.rs @@ -2131,7 +2131,10 @@ fn import_themed_tokens( if set_names.is_empty() { return false; } - set_names.sort_by_key(|name| order.iter().position(|o| o == name).unwrap_or(usize::MAX)); + // Each set's place in the order once: a position search per comparison was quadratic in + // the order's length for every one of the sort's comparisons. + let place: BTreeMap<&str, usize> = order.iter().enumerate().map(|(i, o)| (*o, i)).collect(); + set_names.sort_by_key(|name| place.get(name).copied().unwrap_or(usize::MAX)); let set_names: Vec<&str> = set_names.into_iter().take(512).collect(); let mut tokens: BTreeMap<&str, BTreeMap> = BTreeMap::new(); for name in &set_names { diff --git a/crates/format/src/rest.rs b/crates/format/src/rest.rs index 4604b73a6..9c1d27dde 100644 --- a/crates/format/src/rest.rs +++ b/crates/format/src/rest.rs @@ -427,6 +427,9 @@ pub(crate) struct Ctx<'a> { component_meta: BTreeMap<&'a str, &'a Value>, set_meta: BTreeMap<&'a str, &'a Value>, style_meta: BTreeMap<&'a str, &'a Value>, + /// The ids of the styles imported so far (`doc.styles` is a Vec: a lookup per reference + /// would be quadratic in a document with many distinct references). + style_ids: BTreeSet, /// Instances (imported as frames until their main is resolved) with their JSON. instances: Vec<(NodeId, &'a Value)>, /// `SLOT` layers (imported as frames until their slot property is resolved) with their JSON. @@ -468,6 +471,7 @@ impl<'a> Ctx<'a> { component_meta: BTreeMap::new(), set_meta: BTreeMap::new(), style_meta: BTreeMap::new(), + style_ids: BTreeSet::new(), instances: Vec::new(), slots: Vec::new(), slot_defaults: BTreeMap::new(), @@ -941,6 +945,7 @@ impl<'a> Ctx<'a> { }; self.bindings(&mut sample, node); let bound = sample.bound.iter().filter_map(|(k, v)| Some((style_binding(slot, k)?, v.clone()))).collect(); + self.style_ids.insert(id.to_string()); self.doc.styles.push(Style { id: id.to_string(), name, description, kind, bound }); } } @@ -961,13 +966,14 @@ impl<'a> Ctx<'a> { _ => continue, }; *target = Some(sid.to_string()); - if self.doc.styles.iter().any(|s| s.id == sid) { + if self.style_ids.contains(sid) { continue; } let meta = self.style_meta.get(sid); let name = meta.and_then(|m| text(m, "name")).filter(|s| !s.is_empty()).map_or_else(|| format!("Style {sid}"), |s| clip_str(s).to_string()); let description = meta.and_then(|m| text(m, "description")).map(|s| clip_str(s).to_string()).unwrap_or_default(); + self.style_ids.insert(sid.to_string()); self.doc.styles.push(Style { id: sid.to_string(), name, description, kind, bound: BTreeMap::new() }); } } diff --git a/crates/format/src/rest/import_regression_tests.rs b/crates/format/src/rest/import_regression_tests.rs index 71d9e0afb..5aeb4548b 100644 --- a/crates/format/src/rest/import_regression_tests.rs +++ b/crates/format/src/rest/import_regression_tests.rs @@ -75,3 +75,21 @@ fn stroke_colour_variable_on_outlined_vector_is_kept() { icon.bound ); } + +#[test] +fn thousands_of_distinct_style_references_import_in_linear_time() { + // Each reference used to search every style imported so far (quadratic in their number). + let fill = json!({"type": "SOLID", "color": {"r": 1, "g": 0, "b": 0, "a": 1}}); + let n = 20_000; + let children: Vec = (0..n) + .map(|i| { + json!({"id": format!("1:{i}"), "name": "R", "type": "RECTANGLE", "relativeTransform": at(0.0, 0.0), "size": {"x": 10, "y": 10}, + "fills": [fill], "styles": {"fill": format!("S:{i}")}}) + }) + .collect(); + let v = file(Value::Array(children)); + let start = std::time::Instant::now(); + let (doc, _) = import(&v, &no_images).unwrap(); + assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed()); + assert_eq!(doc.styles.len(), n); +} From 23792e8503a750c1260afe7d2db1384d5e972470 Mon Sep 17 00:00:00 2001 From: Markus Reimr Date: Wed, 4 Nov 2026 14:32:29 +0100 Subject: [PATCH 06/18] M10: translate German text-wrap controls and tooltips --- ROADMAP.md | 4 +++- crates/ui-egui/locales/de.toml | 6 ++++++ docs/localization-parity.md | 3 ++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 410705919..7ceeeaf67 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (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 analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); 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); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); 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); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); 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); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; 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); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); 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; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; 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; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); 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); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (six German translations for the text-wrap control (M10): Auto, balanced and pretty wrapping; catalog remains a draft; 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 analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); 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); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); 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); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); 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); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; 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); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); 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; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; 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; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); 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); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -132,6 +132,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-11-04** · M10: the text layer's Wrap style control and its Balance/Pretty options and three explanatory tooltips now have German translations. These six UI strings previously fell back to English. German stays a draft; no full native review claimed. Grade and estimates unchanged. - **2026-10-30**: M8, #1125: damaged `trak` size-table offsets are checked instead of wrapping on 32-bit targets, and an incomplete normal track is dropped entirely, never returned in part. Font indexing makes its existing bounds explicit, preserving later faces and sparse name-table reads. Synthetic regression tests in `fontdb_tests.rs`. Numbers unchanged. - **2026-10-30** · Text style dedupe is O(spans) (M8, #1102): `shape_text` deduped spans into styles by a scan over the styles already collected — O(spans × styles), so a styled text's dedupe grew with the square of its runs (a synthetic 50k-run text with 100 alternating styles spent 7.7 ms in it). Now the last two styles again (spans run in blocks), then buckets keyed by a cheap fingerprint (family and style bytes, size bits with −0.0 folded, the enums, fill count and solid colours), each bucket resolved with the full equality: the result is the scan's, never worse than it, and O(spans) when names and sizes differ (the same synthetic text: 2.6 ms). The font-db write lock is untouched: `find()` loads faces, which needs the write. `layout_dedupe_tests`. Numbers unchanged. - **2026-10-29** · M10: correct nine German labels in `de.toml`: gradient stops use Verlaufsstopp, OpenType ss01–ss05 use Stilsatz, and the Home-screen preference names the Startbildschirm consistently in the UI and Actions palette. This is a partial terminology pass; German remains a draft awaiting a full native-speaker review and maintainer approval. Numbers unchanged. @@ -357,6 +358,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-11-04 | minor | six German translations for the text-wrap control (M10): Auto, balanced and pretty wrapping; catalog remains a draft. Six previously missing UI entries added to `de.toml`; command labels and menu wordings unchanged. Full native review is still pending; grade and estimates unchanged | | 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 | | 2026-10-31 | patch | M11: a trackpad or touch tap sends its press and release in one frame, so a plugin panel's field committed in the frame its button was clicked and the value was lost: the Counter's Set count… kept the old count. The plugin now gets both, the field's value first. The `window.figcraft` docs name `screenshot`'s `data`. Numbers unchanged | | 2026-10-31 | patch | M4: Dash and Custom strokes have a Dash cap (None, Round, Square) in the advanced stroke settings and `node.set {dashCap}`, on both ends of every dash on the canvas and in PNG, PDF and SVG. Unset, dashes keep taking the end point caps, so older files look the same; the saved file writes it only when set. Numbers unchanged | diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 344e70912..765203109 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -147,6 +147,7 @@ status = "draft — needs native review" "Auto layout" = "Auto-Layout" "Auto rows" = "Automatische Zeilen" "Auto width" = "Automatische Breite" +"Auto: as many words as fit on each line" = "Auto: Jede Zeile wird so weit wie möglich gefüllt" "Back to the FigCraft server" = "Zurück zum FigCraft-Server" "Back to the font's value" = "Zurück zum Wert der Schrift" "Back" = "Zurück" @@ -160,6 +161,8 @@ status = "draft — needs native review" "Back to the values the font style implies" = "Zurück zu den Werten des Schriftschnitts" "Background" = "Hintergrund" "Background blur" = "Hintergrundunschärfe" +"Balance" = "Ausgeglichen" +"Balance: every line close to the same length" = "Ausgeglichen: Alle Zeilen sind möglichst gleich lang" "Basics" = "Grundlagen" "Before" = "Vorher" "Bend" = "Biegen" @@ -1021,6 +1024,8 @@ status = "draft — needs native review" "Post" = "Senden" "Preferences" = "Einstellungen" "Preferred" = "Bevorzugt" +"Pretty" = "Optimiert" +"Pretty: full lines, no one word alone on the last" = "Optimiert: Volle Zeilen ohne einzelnes Wort in der letzten Zeile" "Present" = "Präsentieren" "Present or preview" = "Präsentieren oder Vorschau" "Present this flow" = "Diesen Ablauf präsentieren" @@ -1555,6 +1560,7 @@ status = "draft — needs native review" "With the layers' export settings" = "Mit den Exporteinstellungen der Ebenen" "Wrap" = "Umbrechen" "Wrap in new section" = "In neuen Abschnitt einschließen" +"Wrap style" = "Umbruchstil" "Yellow" = "Gelb" "Yesterday" = "Gestern" "You" = "Sie" diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 541e7ab90..6a831bffc 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (Bosnian draft catalog, one/few/other plurals; German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft; Arabic draft catalog, count-neutral plurals and web font gate; Thai draft catalog and Thai UI fonts; a missing translation fails no check: `cargo xtask i18n-report` lists them; Vietnamese draft catalog; string counts re-measured: 2,352 entries; Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (six German translations for the text-wrap control (M10): Auto, balanced and pretty wrapping; catalog remains a draft; Bosnian draft catalog, one/few/other plurals; German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft; Arabic draft catalog, count-neutral plurals and web font gate; Thai draft catalog and Thai UI fonts; a missing translation fails no check: `cargo xtask i18n-report` lists them; Vietnamese draft catalog; string counts re-measured: 2,352 entries; Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed) · **Target:** Figma Design (desktop app 126.10.7) FigCraft's interface has a localization layer with an English catalog, a Russian catalog reviewed by a native speaker, and Spanish (with a Latin American overlay), French, German, Brazilian @@ -125,6 +125,7 @@ Hindi and Arabic. Traditional Chinese is not planned (no catalog; its users see | Date | Change | Summary | |---|---|---| +| 2026-11-04 | minor | six German translations for the text-wrap control (M10): Auto, balanced and pretty wrapping; catalog remains a draft. Six previously missing UI entries added to `de.toml`; command labels and menu wordings unchanged. Full native review is still pending; grade and estimates unchanged | | 2026-10-30 | minor | Bosnian (`bs`, Bosanski) draft catalog: every UI string (1,906), command label (629) and menu wording (31), machine-drafted in Latin script with ijekavian forms and marked as a draft awaiting native review. Bosnian plurals: one for 1, 21, 31… (not 11), few for 2–4, 22–24… (not 12–14), other for the rest. Grade and hours unchanged (Bosnian is not one of the twelve key languages) | | 2026-10-29 | minor | German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft. Nine existing translations corrected; keys, placeholders, coverage, grade and estimates unchanged | | 2026-10-28 | minor | Arabic draft: 1,857 UI entries, 620 command labels and 31 menu wordings, with count-neutral plurals. AI-assisted, native review pending. Desktop uses the existing Arabic fallback and bidi; web requires an embedded Arabic face. No panel mirroring or parity-grade increase claimed | From 05d1b147fd0c70424365e09ec00ba6d24fb82e41 Mon Sep 17 00:00:00 2001 From: Markus Reimr Date: Wed, 4 Nov 2026 16:17:10 +0100 Subject: [PATCH 07/18] M10: synchronize measured German localization coverage --- ROADMAP.md | 13 +++++++++---- docs/localization-parity.md | 15 ++++++++++++--- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 7ceeeaf67..6e29e428f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (six German translations for the text-wrap control (M10): Auto, balanced and pretty wrapping; catalog remains a draft; 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 analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); 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); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); 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); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); 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); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; 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); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); 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; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; 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; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); 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); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (German coverage re-measured at 2,592 / 2,647 entries (97.9%, 2026-11-04); six German translations for the text-wrap control (M10): Auto, balanced and pretty wrapping; catalog remains a draft; 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 analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); 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); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); 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); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); 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); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; 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); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); 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; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; 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; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); 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); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -97,7 +97,7 @@ native speakers' reviews of the nine language drafts need people. ## Languages -| Language | Code | Strings | Status | +| Language | Code | Catalog entries | Status | |---|---|---|---| | English | en | 100% | full | | Simplified Chinese | zh-Hans | 95% | draft (needs native review) | @@ -108,11 +108,14 @@ native speakers' reviews of the nine language drafts need people. | Portuguese (Brazil) | pt-BR | 95% | draft (needs native review) | | Indonesian | id | 99% | draft (needs native review) | | Japanese | ja | 99% | draft (needs native review) | -| German | de | 96% | draft (needs native review) | +| German | de | 2,592 / 2,647 entries (97.9%, measured 2026-11-04) | draft (needs native review) | | Korean | ko | 95% | draft (needs native review) | | Vietnamese | vi | 100% | draft (needs native review) | -Percentages measured 2026-10-17 over 2,272 entries (strings added since a draft show English). Also shipped: Russian (`ru`, 99%, reviewed by VanTan), Swedish (`sv`, 99.6%, command labels since 2026-10-17; native review welcome), Turkish (`tr`, 99%), Czech (`cs`, 99%, draft) and Bosnian (`bs`, 100% on 2026-10-30, draft). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese +German coverage was re-measured on 2026-11-04 with `cargo xtask i18n-report`: 1,969 / 1,975 UI keys, +594 / 641 command labels and 29 / 31 menu wordings, or 2,592 / 2,647 entries (97.9%). The count measures +catalog entries, not native-speaker approval; the method is in [localization parity](docs/localization-parity.md#languages). +The older percentages use the 2026-10-17 measurement over 2,272 entries (strings added since a draft show English); rows with a later date supersede it. Also shipped: Russian (`ru`, 99%, reviewed by VanTan), Swedish (`sv`, 99.6%, command labels since 2026-10-17; native review welcome), Turkish (`tr`, 99%), Czech (`cs`, 99%, draft) and Bosnian (`bs`, 100% on 2026-10-30, draft). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese (Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian, Vietnamese and Arabic drafts (Preferences ▸ Language; Traditional Chinese has no catalog; CJK UI @@ -132,6 +135,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-11-04** · M10: re-measure German catalog coverage with `cargo xtask i18n-report` and synchronize both language rows at 2,592 / 2,647 entries (97.9%). Record the current key counts and counting method; distinguish the older dated measurements. German remains a draft; grade and estimates unchanged. - **2026-11-04** · M10: the text layer's Wrap style control and its Balance/Pretty options and three explanatory tooltips now have German translations. These six UI strings previously fell back to English. German stays a draft; no full native review claimed. Grade and estimates unchanged. - **2026-10-30**: M8, #1125: damaged `trak` size-table offsets are checked instead of wrapping on 32-bit targets, and an incomplete normal track is dropped entirely, never returned in part. Font indexing makes its existing bounds explicit, preserving later faces and sparse name-table reads. Synthetic regression tests in `fontdb_tests.rs`. Numbers unchanged. - **2026-10-30** · Text style dedupe is O(spans) (M8, #1102): `shape_text` deduped spans into styles by a scan over the styles already collected — O(spans × styles), so a styled text's dedupe grew with the square of its runs (a synthetic 50k-run text with 100 alternating styles spent 7.7 ms in it). Now the last two styles again (spans run in blocks), then buckets keyed by a cheap fingerprint (family and style bytes, size bits with −0.0 folded, the enums, fill count and solid colours), each bucket resolved with the full equality: the result is the scan's, never worse than it, and O(spans) when names and sizes differ (the same synthetic text: 2.6 ms). The font-db write lock is untouched: `find()` loads faces, which needs the write. `layout_dedupe_tests`. Numbers unchanged. @@ -358,6 +362,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-11-04 | minor | German coverage re-measured with `cargo xtask i18n-report`: 1,969 / 1,975 UI keys, 594 / 641 command labels and 29 / 31 menu wordings; both language rows now show 2,592 / 2,647 (97.9%). Missing: 6 UI keys, 47 command labels, 2 menu wordings. Older measurements are dated; native review, grade and estimates unchanged | | 2026-11-04 | minor | six German translations for the text-wrap control (M10): Auto, balanced and pretty wrapping; catalog remains a draft. Six previously missing UI entries added to `de.toml`; command labels and menu wordings unchanged. Full native review is still pending; grade and estimates unchanged | | 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 | | 2026-10-31 | patch | M11: a trackpad or touch tap sends its press and release in one frame, so a plugin panel's field committed in the frame its button was clicked and the value was lost: the Counter's Set count… kept the old count. The plugin now gets both, the field's value first. The `window.figcraft` docs name `screenshot`'s `data`. Numbers unchanged | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 6a831bffc..e4b720639 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (six German translations for the text-wrap control (M10): Auto, balanced and pretty wrapping; catalog remains a draft; Bosnian draft catalog, one/few/other plurals; German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft; Arabic draft catalog, count-neutral plurals and web font gate; Thai draft catalog and Thai UI fonts; a missing translation fails no check: `cargo xtask i18n-report` lists them; Vietnamese draft catalog; string counts re-measured: 2,352 entries; Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (German coverage re-measured at 2,592 / 2,647 entries (97.9%, 2026-11-04); six German translations for the text-wrap control (M10): Auto, balanced and pretty wrapping; catalog remains a draft; Bosnian draft catalog, one/few/other plurals; German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft; Arabic draft catalog, count-neutral plurals and web font gate; Thai draft catalog and Thai UI fonts; a missing translation fails no check: `cargo xtask i18n-report` lists them; Vietnamese draft catalog; string counts re-measured: 2,352 entries; Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed) · **Target:** Figma Design (desktop app 126.10.7) FigCraft's interface has a localization layer with an English catalog, a Russian catalog reviewed by a native speaker, and Spanish (with a Latin American overlay), French, German, Brazilian @@ -85,7 +85,7 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v ## Languages -| Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | Hours to full | +| Language | Code | Translated catalog entries | Dialogs / tooltips / help | Script support | Native review | Status | Hours to full | |---|---|---|---|---|---|---|---| | English | en | 2,352 / 2,352 (100%, source) | yes | Latin | yes (source) | full | 0 | | Simplified Chinese (Mandarin) | zh-Hans | 2,216 / 2,352 (94.2%, draft) | yes | CJK text and IME in documents; UI text in an installed Chinese face, ahead of Japanese ones (web: the `FIGCRAFT_EMBED_CJK` build); no Traditional Chinese catalog (zh-TW, zh-HK read English) | no | draft | 1–2 (after native review) | @@ -96,11 +96,19 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v | Portuguese | pt-BR | 2,331 / 2,352 (99.1%, draft) | yes | Latin | no | draft | 1–2 (after native review) | | Indonesian | id | 2,292 / 2,352 (97.4%, draft) | yes | Latin | no | draft | 1–2 (after native review) | | Japanese | ja | 2,304 / 2,352 (98.0%, draft) | yes | CJK text and IME in documents; UI text in an installed Japanese face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | -| German | de | 2,226 / 2,352 (94.6%, draft) | yes | Latin; long words elide in narrow fields | no | draft | 1–2 (after native review) | +| German | de | 2,592 / 2,647 entries (97.9%, measured 2026-11-04) | yes | Latin; long words elide in narrow fields | no | draft | 1–2 (after native review) | | Korean | ko | 2,216 / 2,352 (94.2%, draft) | yes | Hangul text and IME in documents; UI text in an installed Korean face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | | Vietnamese | vi | 2,352 / 2,352 (100%, draft) | yes | Latin with stacked diacritics | no | draft | 1–2 (after native review) | | Thai | th | 1,791 / 1,791 UI strings, 589 of 598 command labels, 31 menu wordings (draft) | yes | Thai text in documents; UI text (and layer and page names in Thai, whatever the UI language) in an installed Thai face, desktop only; the web build leaves Thai out | no | draft | 1–2 (after native review) | +German was re-measured on **2026-11-04** with `cargo xtask i18n-report`: **1,969 / 1,975 UI keys**, +**594 / 641 command labels** and **29 / 31 menu wordings**, totaling **2,592 / 2,647 entries (97.9%)**. +The report confirms 6 UI keys, 47 command labels and 2 menu wordings are missing. The denominator uses +the report's key sets: English UI keys, the union of registered command ids and main-menu ids, +and menu labels worded differently from their command. A plural entry counts as one key, and a +present translation counts even when its wording matches English. This is catalog coverage; +German remains a draft awaiting full native-speaker review. The other rows retain their dated measurements. + Other languages (measured 2026-10-22): Russian (`ru`) 2,352 / 2,352 (100%), reviewed and approved by VanTan, one/few/many integer plurals; Swedish (`sv`) 2,314 / 2,352 (98.4%), its command labels and menu wordings added 2026-10-17 (#599), native review welcome; Turkish (`tr`) 2,291 / 2,352 (97.4%), whose header @@ -125,6 +133,7 @@ Hindi and Arabic. Traditional Chinese is not planned (no catalog; its users see | Date | Change | Summary | |---|---|---| +| 2026-11-04 | minor | German coverage re-measured with `cargo xtask i18n-report`: 1,969 / 1,975 UI keys, 594 / 641 command labels and 29 / 31 menu wordings; both language rows now show 2,592 / 2,647 (97.9%). Missing: 6 UI keys, 47 command labels, 2 menu wordings. Older measurements are dated; native review, grade and estimates unchanged | | 2026-11-04 | minor | six German translations for the text-wrap control (M10): Auto, balanced and pretty wrapping; catalog remains a draft. Six previously missing UI entries added to `de.toml`; command labels and menu wordings unchanged. Full native review is still pending; grade and estimates unchanged | | 2026-10-30 | minor | Bosnian (`bs`, Bosanski) draft catalog: every UI string (1,906), command label (629) and menu wording (31), machine-drafted in Latin script with ijekavian forms and marked as a draft awaiting native review. Bosnian plurals: one for 1, 21, 31… (not 11), few for 2–4, 22–24… (not 12–14), other for the rest. Grade and hours unchanged (Bosnian is not one of the twelve key languages) | | 2026-10-29 | minor | German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft. Nine existing translations corrected; keys, placeholders, coverage, grade and estimates unchanged | From 5244b0075d85b318b98e484a197087e99eb89fc0 Mon Sep 17 00:00:00 2001 From: JonRC Date: Wed, 4 Nov 2026 23:53:43 -0300 Subject: [PATCH 08/18] fix(engine): an unknown stroke cap lists the caps node.set takes (#2005) strokeCap, startCap and endCap answered only "unknown stroke cap". They now name the key and every cap, as strokeAlign, strokeJoin and dashCap do. --- crates/engine/src/cmd/props.rs | 9 ++++--- crates/engine/src/lib.rs | 2 ++ crates/engine/src/stroke_cap_error_tests.rs | 27 +++++++++++++++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) create mode 100644 crates/engine/src/stroke_cap_error_tests.rs diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index abd380cfc..ee606f2e1 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -196,6 +196,9 @@ fn set_operand_op(doc: &mut Document, id: NodeId, v: &Value, cmd: &str) -> Resul pub(crate) const WIDTH_PROFILE_HELP: &str = "widthProfile: a preset (uniform|wedge|taper|eye|rounded|pinch), points [[t (0-1 along the path), scale (× stroke weight)] | [t, left, right] (one-sided: each side's scale, looking along the path), ...] or [{t, scale} | {t, left, right}], {preset} | {points}, or null (uniform)"; +/// The stroke caps `strokeCap`, `startCap` and `endCap` take (every [`StrokeCap`]). +pub(crate) const STROKE_CAP_HELP: &str = "none|round|square|lineArrow|triangleArrow|reversedTriangle|circleFilled|diamondFilled"; + pub(crate) const BRUSH_HELP: &str = "brush: a preset (taper|ink|ribbon|bristle: stretched; dots|stars|confetti|leaves: scattered), {preset, spacing? (× weight), sizeJitter? (0-1), rotationJitter? (0-1), scatter? (0-4 × weight), seed?}, or null (a plain stroke)"; /// A brush from a preset id, `{preset, spacing?, sizeJitter?, rotationJitter?, scatter?, seed?}` @@ -611,13 +614,13 @@ fn apply_look(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) -> st.sides = Some(sides); } "strokeCap" => { - let c: StrokeCap = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?; + let c: StrokeCap = enum_of(v).ok_or_else(|| bad(cmd, format!("strokeCap: {STROKE_CAP_HELP}")))?; let n = doc.get_mut(id)?; n.stroke.cap_start = c; n.stroke.cap_end = c; } - "startCap" => doc.get_mut(id)?.stroke.cap_start = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?, - "endCap" => doc.get_mut(id)?.stroke.cap_end = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?, + "startCap" => doc.get_mut(id)?.stroke.cap_start = enum_of(v).ok_or_else(|| bad(cmd, format!("startCap: {STROKE_CAP_HELP}")))?, + "endCap" => doc.get_mut(id)?.stroke.cap_end = enum_of(v).ok_or_else(|| bad(cmd, format!("endCap: {STROKE_CAP_HELP}")))?, "strokeJoin" => doc.get_mut(id)?.stroke.join = enum_of(v).ok_or_else(|| bad(cmd, "strokeJoin: miter|bevel|round"))?, "miterLimit" => doc.get_mut(id)?.stroke.miter_limit = f64_of(v).unwrap_or(4.0).clamp(1.0, 100.0), "miterAngle" => { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e919444e2..f7becee87 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -545,6 +545,8 @@ mod slot_tests; #[cfg(test)] mod stored_ids_tests; #[cfg(test)] +mod stroke_cap_error_tests; +#[cfg(test)] mod stroke_style_tests; #[cfg(test)] mod style_tests; diff --git a/crates/engine/src/stroke_cap_error_tests.rs b/crates/engine/src/stroke_cap_error_tests.rs new file mode 100644 index 000000000..11e414d5c --- /dev/null +++ b/crates/engine/src/stroke_cap_error_tests.rs @@ -0,0 +1,27 @@ +//! An unknown stroke cap is refused with the caps there are, as `strokeAlign` and `strokeJoin` +//! list theirs (#2005). + +use serde_json::json; + +use crate::Session; +use crate::cmd::props::STROKE_CAP_HELP; +use crate::params::id_of; +use figcraft_doc::StrokeCap; + +#[test] +fn an_unknown_cap_lists_every_cap() { + let names: Vec = StrokeCap::ALL.iter().map(|c| serde_json::to_value(c).unwrap().as_str().unwrap().to_string()).collect(); + assert_eq!(STROKE_CAP_HELP.split('|').collect::>(), names, "the list is every cap, in order"); + let mut s = Session::new(); + let v = id_of(&s.execute("vector.create", &json!({"path": "M0 0 L200 0", "x": 0, "y": 0})).unwrap()["id"]).unwrap(); + for key in ["strokeCap", "startCap", "endCap"] { + for junk in [json!("butt"), json!(3), json!("")] { + let e = s.execute("node.set", &json!({"id": v, "props": {key: junk}})).unwrap_err().to_string(); + assert!(e.contains(&format!("{key}: {STROKE_CAP_HELP}")), "{key} {junk}: {e}"); + } + // Every name the error lists is taken. + for name in &names { + s.execute("node.set", &json!({"id": v, "props": {key: name}})).unwrap_or_else(|e| panic!("{key} {name}: {e}")); + } + } +} From a55a728c4c5ab39bd2c3c928afc97b9ac5f879b4 Mon Sep 17 00:00:00 2001 From: JonRC Date: Thu, 5 Nov 2026 00:09:19 -0300 Subject: [PATCH 09/18] M6: JPG exports take a quality and embed the sRGB profile (#2004) file.export always encoded JPG at quality 90, and an sRGB document's JPGs carried no colour profile, so viewers could only assume sRGB. PNG exports always get an sRGB chunk, and Display P3 JPGs already embedded their ICC profile. - ExportSetting gets a quality (1 to 100, default 90, saved only when it isn't 90), parsed with the other export options: file.export, export.add, export.update, file.exportLayers and MCP export take it. - encode_export_with takes the quality; encode_export keeps quality 90. Every JPG now embeds the document's profile, sRGB included. --- crates/doc/src/props.rs | 16 ++++ crates/engine/src/cmd/edit.rs | 4 +- crates/engine/src/cmd/file.rs | 5 +- crates/engine/src/cmd/file/export.rs | 17 +++- crates/engine/src/jpg_export_tests.rs | 115 ++++++++++++++++++++++++++ crates/engine/src/lib.rs | 2 + crates/mcp/src/tools.rs | 2 + crates/render/src/lib.rs | 12 ++- crates/render/src/tests.rs | 13 ++- crates/ui-egui/src/ui_params_tests.rs | 1 + docs/file-format-parity.md | 5 +- docs/mcp.md | 2 +- 12 files changed, 180 insertions(+), 14 deletions(-) create mode 100644 crates/engine/src/jpg_export_tests.rs diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index 0c78681f2..b5f1f89cb 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -1481,6 +1481,9 @@ pub struct ExportSetting { /// Android Vector Drawable: what to do with what a drawable can't hold exactly. #[serde(skip_serializing_if = "VdCompat::is_default")] pub vd_compat: VdCompat, + /// JPG: the encoder's quality, 1 (smallest file) to 100 (see [`ExportSetting::quality`]). + #[serde(skip_serializing_if = "is_default_quality")] + pub quality: u8, } /// How an Android Vector Drawable export handles what the format can't draw exactly (layer @@ -1531,6 +1534,7 @@ impl Default for ExportSetting { svg_id_attribute: false, svg_simplify_stroke: true, vd_compat: VdCompat::BestEffort, + quality: ExportSetting::DEFAULT_QUALITY, } } } @@ -1543,7 +1547,19 @@ fn is_false(b: &bool) -> bool { !*b } +fn is_default_quality(q: &u8) -> bool { + *q == ExportSetting::DEFAULT_QUALITY +} + impl ExportSetting { + /// The JPG quality a setting has unless it sets one. + pub const DEFAULT_QUALITY: u8 = 90; + + /// The JPG quality made safe: within `1..=100`. + pub fn quality(&self) -> u8 { + self.quality.clamp(1, 100) + } + /// The row adding a setting after `last` creates: 1x first, then one scale step up with a /// matching "@2x"-style suffix. pub fn next_after(last: Option<&ExportSetting>) -> ExportSetting { diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 700fa7414..f6665805a 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -418,7 +418,7 @@ pub fn specs() -> Vec { spec!( "export.add", "Add export setting", - "{ids?, setting? {size: 2x|512w|512h|{by: scale|width|height, value}, suffix?, format?: png|jpg|svg|pdf|webp|xml (Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore)}} (without a setting: 1x, then one scale step up with an \"@2x\"-style suffix)", + "{ids?, setting? {size: 2x|512w|512h|{by: scale|width|height, value}, suffix?, format?: png|jpg|svg|pdf|webp|xml (Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore), quality? (JPG: 1-100, default 90)}} (without a setting: 1x, then one scale step up with an \"@2x\"-style suffix)", true, |s, p| { let cmd = "export.add"; @@ -436,7 +436,7 @@ pub fn specs() -> Vec { spec!( "export.update", "Edit export setting", - "{ids?, index, size? (2x|512w|512h|{by, value}), suffix?, format? (png|jpg|svg|pdf|webp|xml: Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore), contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?}", + "{ids?, index, size? (2x|512w|512h|{by, value}), suffix?, format? (png|jpg|svg|pdf|webp|xml: Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore), quality? (JPG: 1-100), contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?}", true, |s, p| { let cmd = "export.update"; diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 0cfdd1ffa..c25813381 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -118,7 +118,7 @@ pub fn specs() -> Vec { spec!( "file.export", "Export", - "{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp|xml (an Android Vector Drawable, with `warnings` for what it changed or left out), or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file; or penpot: the whole file as a Penpot 2.x archive, with warnings for unsupported content), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), contentsOnly? (default true; false also draws the layers that show in their area), includeBoundingBox? (true: only the layers' own boxes, cutting off shadows and outside strokes), svgOutlineText? (default true), svgSimplifyStroke? (default true), svgIdAttribute? (SVG ids, XML android:name), vdCompat? (XML: bestEffort (default: approximate with a warning)|exact (leave out what can't be exact, with a warning)|ignore (no warnings))}", + "{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp|xml (an Android Vector Drawable, with `warnings` for what it changed or left out), or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file; or penpot: the whole file as a Penpot 2.x archive, with warnings for unsupported content), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), contentsOnly? (default true; false also draws the layers that show in their area), includeBoundingBox? (true: only the layers' own boxes, cutting off shadows and outside strokes), svgOutlineText? (default true), svgSimplifyStroke? (default true), svgIdAttribute? (SVG ids, XML android:name), vdCompat? (XML: bestEffort (default: approximate with a warning)|exact (leave out what can't be exact, with a warning)|ignore (no warnings)), quality? (JPG: 1-100, default 90)}", false, export ), @@ -408,7 +408,8 @@ pub fn export_file(doc: &Document, ids: &[NodeId], setting: &ExportSetting, scal _ => { let pix = figcraft_render::render_area(doc, &drawn, area, scale) .ok_or_else(|| EngineError::Other("render failed (empty or too large)".into()))?; - Ok((figcraft_render::encode_export(&pix, setting.format, doc.color_profile).map_err(EngineError::Other)?, Vec::new())) + let bytes = figcraft_render::encode_export_with(&pix, setting.format, doc.color_profile, setting.quality()); + Ok((bytes.map_err(EngineError::Other)?, Vec::new())) } } } diff --git a/crates/engine/src/cmd/file/export.rs b/crates/engine/src/cmd/file/export.rs index 68e003d48..25b5629a1 100644 --- a/crates/engine/src/cmd/file/export.rs +++ b/crates/engine/src/cmd/file/export.rs @@ -1,6 +1,7 @@ use super::load::{ext, write}; use super::save::thumbnail; use super::*; +use crate::params::f64_of; /// Encode `ids` (or the page) in `format` at `scale`. pub fn export_bytes(doc: &Document, ids: &[NodeId], format: ExportFormat, scale: f64) -> Result> { @@ -159,7 +160,8 @@ pub fn export_scale(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> export_area(doc, ids, setting).map_or(1.0, |b| setting.size.scale_for(b.width(), b.height())) } -/// `e` with the `size` ("2x", "512w", "512h" or `{by, value}`), `suffix` and `format` that `p` sets. +/// `e` with the `size` ("2x", "512w", "512h" or `{by, value}`), `suffix`, `format` and the +/// format options (`quality`, `vdCompat`, PDF print options, the SVG flags) that `p` sets. pub(in crate::cmd) fn export_setting(cmd: &str, mut e: ExportSetting, p: &Value) -> Result { if let Some(size) = size_param(cmd, p)? { e.size = size; @@ -173,6 +175,9 @@ pub(in crate::cmd) fn export_setting(cmd: &str, mut e: ExportSetting, p: &Value) if let Some(c) = vd_compat_param(cmd, p)? { e.vd_compat = c; } + if let Some(q) = quality_param(cmd, p)? { + e.quality = q; + } e.print = print_param(cmd, p, e.print)?; for (key, slot) in [ ("contentsOnly", &mut e.contents_only), @@ -189,6 +194,16 @@ pub(in crate::cmd) fn export_setting(cmd: &str, mut e: ExportSetting, p: &Value) Ok(e) } +/// The JPG `quality` `p` passes: a whole number from 1 to 100 (anything else is an error). +fn quality_param(cmd: &str, p: &Value) -> Result> { + let Some(v) = get(p, "quality") else { return Ok(None) }; + let q = if v.is_boolean() { None } else { f64_of(v) }; + match q.filter(|q| q.fract() == 0.0 && (1.0..=100.0).contains(q)) { + Some(q) => Ok(Some(q as u8)), + None => Err(bad(cmd, "quality: a whole number from 1 to 100 (JPG)")), + } +} + /// Whether `p` sets any PDF print option. fn has_print_param(p: &Value) -> bool { ["bleed", "cropMarks", "bleedMarks", "cmyk"].iter().any(|k| get(p, k).is_some_and(|v| !v.is_null())) diff --git a/crates/engine/src/jpg_export_tests.rs b/crates/engine/src/jpg_export_tests.rs new file mode 100644 index 000000000..37e85af8b --- /dev/null +++ b/crates/engine/src/jpg_export_tests.rs @@ -0,0 +1,115 @@ +//! JPG exports: the quality an export or export setting asks for, and the colour profile every +//! JPG embeds (#2004). + +use image::ImageDecoder; +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; +use figcraft_color::ColorProfile; +use figcraft_doc::{ExportSetting, NodeId}; + +/// A rectangle with a gradient, so the quality shows in the file's size. +fn gradient_rect(s: &mut Session) -> NodeId { + let r = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 200, "height": 120})).unwrap()["id"]).unwrap(); + let fill = json!({"type": "linearGradient", "stops": ["#FF0000", "#00FF88", "#2040FF"]}); + s.execute("node.set", &json!({"ids": [r], "props": {"fills": [fill]}})).unwrap(); + r +} + +fn bytes(v: &Value) -> Vec { + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, v["data"].as_str().unwrap()).unwrap() +} + +fn jpg(s: &mut Session, r: NodeId, extra: Value) -> Vec { + let mut p = json!({"ids": [r], "format": "jpg"}); + if let (Value::Object(m), Value::Object(e)) = (&mut p, extra) { + m.extend(e); + } + bytes(&s.execute("file.export", &p).unwrap()) +} + +fn icc(jpg: &[u8]) -> Option> { + image::codecs::jpeg::JpegDecoder::new(std::io::Cursor::new(jpg)).unwrap().icc_profile().unwrap() +} + +#[test] +fn jpg_quality_is_taken_and_defaults_to_90() { + let mut s = Session::new(); + let r = gradient_rect(&mut s); + let default = jpg(&mut s, r, json!({})); + assert_eq!(default, jpg(&mut s, r, json!({"quality": 90})), "90 is the default"); + let low = jpg(&mut s, r, json!({"quality": 20})); + let high = jpg(&mut s, r, json!({"quality": 100})); + assert!(low.len() < default.len() && default.len() < high.len(), "{} {} {}", low.len(), default.len(), high.len()); + // Agents send numbers as strings too. + assert_eq!(jpg(&mut s, r, json!({"quality": "20"})), low); + image::load_from_memory(&low).unwrap(); + // The other formats ignore it. + let png = |s: &mut Session, extra: Value| { + let mut p = json!({"ids": [r], "format": "png"}); + if let (Value::Object(m), Value::Object(e)) = (&mut p, extra) { + m.extend(e); + } + bytes(&s.execute("file.export", &p).unwrap()) + }; + assert_eq!(png(&mut s, json!({"quality": 20})), png(&mut s, json!({}))); +} + +#[test] +fn bad_qualities_are_refused() { + let mut s = Session::new(); + let r = gradient_rect(&mut s); + s.execute("export.add", &json!({"ids": [r], "setting": {"format": "jpg"}})).unwrap(); + for q in [json!(0), json!(101), json!(-5), json!(50.5), json!("high"), json!(true), json!([80]), json!(1e300)] { + let e = s.execute("file.export", &json!({"ids": [r], "format": "jpg", "quality": q})).unwrap_err(); + assert!(e.to_string().contains("quality: a whole number from 1 to 100"), "{q}: {e}"); + assert!(s.execute("export.add", &json!({"ids": [r], "setting": {"format": "jpg", "quality": q}})).is_err(), "{q}"); + assert!(s.execute("export.update", &json!({"ids": [r], "index": 0, "quality": q})).is_err(), "{q}"); + } + let n = s.doc().unwrap().node(r).unwrap(); + assert_eq!(n.exports.len(), 1); + assert_eq!(n.exports[0].quality, ExportSetting::DEFAULT_QUALITY); + // null leaves the setting as it is. + s.execute("export.update", &json!({"ids": [r], "index": 0, "quality": 40})).unwrap(); + s.execute("export.update", &json!({"ids": [r], "index": 0, "quality": null})).unwrap(); + assert_eq!(s.doc().unwrap().node(r).unwrap().exports[0].quality, 40); +} + +#[test] +fn export_settings_keep_their_quality() { + let mut s = Session::new(); + let r = gradient_rect(&mut s); + s.execute("export.add", &json!({"ids": [r], "setting": {"format": "jpg", "quality": 30}})).unwrap(); + s.execute("export.add", &json!({"ids": [r], "setting": {"format": "jpg", "suffix": "-hq"}})).unwrap(); + s.execute("export.update", &json!({"ids": [r], "index": 1, "quality": 95})).unwrap(); + let exports = s.doc().unwrap().node(r).unwrap().exports.clone(); + assert_eq!((exports[0].quality, exports[1].quality), (30, 95)); + // `file.exportLayers` encodes each setting at its own quality. + let files = s.execute("file.exportLayers", &json!({"ids": [r]})).unwrap()["files"].as_array().unwrap().clone(); + assert_eq!(bytes(&files[0]), jpg(&mut s, r, json!({"quality": 30}))); + assert_eq!(bytes(&files[1]), jpg(&mut s, r, json!({"quality": 95}))); + // Saved only when it isn't the default, so older files and settings read as 90. + assert_eq!(serde_json::to_value(&exports[0]).unwrap()["quality"], 30); + assert!(serde_json::to_value(ExportSetting::default()).unwrap().get("quality").is_none()); + let old: ExportSetting = serde_json::from_value(json!({"format": "jpg"})).unwrap(); + assert_eq!(old.quality, ExportSetting::DEFAULT_QUALITY); + // A value out of range in a file is clamped when it encodes. + let odd: ExportSetting = serde_json::from_value(json!({"format": "jpg", "quality": 0})).unwrap(); + assert_eq!(odd.quality(), 1); + // Undo takes the setting's quality back. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().node(r).unwrap().exports[1].quality, ExportSetting::DEFAULT_QUALITY); +} + +#[test] +fn every_jpg_embeds_the_file_colour_profile() { + for profile in ColorProfile::ALL { + let mut s = Session::new(); + s.execute("file.colorProfile", &json!({"profile": profile.label(), "convert": false})).unwrap(); + let r = gradient_rect(&mut s); + for quality in [json!({}), json!({"quality": 10})] { + assert_eq!(icc(&jpg(&mut s, r, quality)), Some(profile.icc()), "{profile:?}"); + } + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e919444e2..a106b743a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -383,6 +383,8 @@ mod invite_links_tests; #[cfg(test)] mod jam_open_tests; #[cfg(test)] +mod jpg_export_tests; +#[cfg(test)] mod keymap_tests; #[cfg(test)] mod library_beacon_tests; diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 1cb141b58..23f871b92 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -280,6 +280,7 @@ fn tools() -> Vec { "svgIdAttribute": boolean("SVG: layer names as unique `id` attributes (default false)"), "svgSimplifyStroke": boolean("SVG: strokes as `stroke` attributes where exact (default true); false writes outlines"), "vdCompat": {"type": "string", "enum": ["bestEffort", "exact", "ignore"], "description": "XML: what a Vector Drawable can't draw exactly is approximated with a warning (bestEffort, default), left out with a warning (exact), or written as far as it maps without warnings (ignore: overlapping layer opacity dropped, even-odd fills kept as fillType=evenOdd, diamond gradients left out)"}, + "quality": {"type": "integer", "minimum": 1, "maximum": 100, "description": "JPG: the encoder's quality, 1 (smallest file) to 100; default 90"}, }), &[], ), @@ -1059,6 +1060,7 @@ fn export(b: &mut dyn Backend, a: &Map) -> Result Result, String> { /// Encode an export in `format` (PNG, JPEG over white, WebP) tagged with the document's colour /// `profile`. An sRGB PNG gets an `sRGB` chunk; a Display P3 PNG gets the profile as `iCCP` plus -/// `cICP` code points; Display P3 JPEG and WebP files embed the ICC profile (untagged files are read -/// as sRGB). SVG, PDF and Vector Drawable are not pixel formats: an error. +/// `cICP` code points; a JPEG embeds the ICC profile, sRGB too (JPEG has no small sRGB marker); +/// a Display P3 WebP embeds it (an untagged WebP is read as sRGB). JPEG at quality 90. SVG, PDF and +/// Vector Drawable are not pixel formats: an error. pub fn encode_export(p: &Pixmap, format: ExportFormat, profile: ColorProfile) -> Result, String> { + encode_export_with(p, format, profile, figcraft_doc::ExportSetting::DEFAULT_QUALITY) +} + +/// [`encode_export`] with the JPEG `quality` (1-100); the other formats ignore it. +pub fn encode_export_with(p: &Pixmap, format: ExportFormat, profile: ColorProfile, quality: u8) -> Result, String> { let icc = (!profile.is_srgb()).then(|| profile.icc()); match format { ExportFormat::Png => { let png = png_with(p, icc)?; Ok(if profile.is_srgb() { png_insert_chunk(png, b"sRGB", &[0]) } else { png_insert_chunk(png, b"cICP", &profile.cicp()) }) } - ExportFormat::Jpg => jpeg_with(p, Color::WHITE, 90, icc), + ExportFormat::Jpg => jpeg_with(p, Color::WHITE, quality, Some(profile.icc())), ExportFormat::Webp => webp_with(p, icc), ExportFormat::Svg | ExportFormat::Pdf | ExportFormat::VectorDrawable => Err(format!("{} is not a pixel format", format.label())), } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index f7cfe5748..8e5943b5a 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -496,9 +496,16 @@ fn exports_carry_the_colour_profile() { let icc = dec.icc_profile().unwrap(); assert_eq!(icc.is_some(), !profile.is_srgb(), "{profile:?}"); assert_eq!(image::load_from_memory(&png).unwrap().to_rgba8().get_pixel(5, 5).0, [0, 255, 0, 255]); - for f in [ExportFormat::Jpg, ExportFormat::Webp] { - assert!(!encode_export(&pix, f, profile).unwrap().is_empty()); - } + // A JPEG always embeds the profile (it has no small sRGB marker); an sRGB WebP is untagged. + let jpg = encode_export(&pix, ExportFormat::Jpg, profile).unwrap(); + let icc = image::codecs::jpeg::JpegDecoder::new(std::io::Cursor::new(&jpg)).unwrap().icc_profile().unwrap(); + assert_eq!(icc, Some(profile.icc()), "{profile:?}"); + assert_eq!(jpg, encode_export_with(&pix, ExportFormat::Jpg, profile, 90).unwrap(), "quality 90 by default"); + assert_ne!(jpg, encode_export_with(&pix, ExportFormat::Jpg, profile, 30).unwrap()); + let webp = encode_export(&pix, ExportFormat::Webp, profile).unwrap(); + let icc = image::codecs::webp::WebPDecoder::new(std::io::Cursor::new(&webp)).unwrap().icc_profile().unwrap(); + assert_eq!(icc.is_some(), !profile.is_srgb(), "{profile:?}"); + assert_eq!(webp, encode_export_with(&pix, ExportFormat::Webp, profile, 30).unwrap(), "WebP takes no quality"); assert!(encode_export(&pix, ExportFormat::Svg, profile).is_err()); } assert_eq!(png_insert_chunk(b"not a png".to_vec(), b"sRGB", &[0]), b"not a png"); diff --git a/crates/ui-egui/src/ui_params_tests.rs b/crates/ui-egui/src/ui_params_tests.rs index 6ee71dfc9..fa6464041 100644 --- a/crates/ui-egui/src/ui_params_tests.rs +++ b/crates/ui-egui/src/ui_params_tests.rs @@ -30,6 +30,7 @@ fn export_settings_with_every_option_are_taken() { svg_id_attribute: true, svg_simplify_stroke: false, vd_compat: VdCompat::Exact, + quality: 70, ..ExportSetting::default() }; let mut p = crate::export_panel::setting_params(&setting); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 56cc701a2..a95ccecc2 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (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: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (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: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -54,7 +54,7 @@ export limit, not a native document nesting limit. | Format | Figma | FigCraft | Notes | Hours | |---|---|---|---|---| | PNG | yes, scales/suffixes | yes | export settings per layer, presets (`2x`, `512w`, `512h`), preview, "Export N layers"; ignore overlapping layers (on by default; off: every layer showing in the area) and include bounding box (the layer's own box; the Export section offers it for text layers, and `file.export` and `export.update` take it for any layer); slashes in layer names export into folders. The exported area is what the layer paints: shadows towards their offset, outside and centre strokes, unclipped children, a mask group's mask, nothing for hidden layers; slices export everything under them, except that with ignore overlapping layers on a slice inside a frame or group exports only that frame or group; sides round up to whole pixels | — | -| JPG | yes | yes | white where transparent; fixed quality (90) | — | +| JPG | yes | yes | white where transparent; quality 1 to 100 (`quality` on `file.export`, export settings and MCP `export`; default 90; the Export panel has no control for it yet); the file's colour profile embedded as ICC (APP2), sRGB too | — | | SVG | yes (outline text option, ids) | yes | alpha/luminance masks kept; noise, texture, progressive and background blur, glass embedded as images; outline text (off: `` with one `` per line, one style per layer), unique id attributes from layer names, explicit depth-limit refusal, nested frames clipped by their parent's clip (#327), simplified strokes (stroke attributes for centred strokes and for inside and outside strokes on boxes and ellipses) | — | | PDF | yes | yes | one page per frame, real selectable text in subset fonts, links, vectors and gradients as vectors, effects as images; bleed, crop/bleed marks, page boxes, naive CMYK; checked by rendering the PDF back against PNG exports; explicit depth-limit refusal | CMYK output intent 2–4 | | WebP | — | yes (ours) | | — | @@ -187,6 +187,7 @@ source paint coordinates. | Date | Change | Summary | |---|---|---| +| 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-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 | patch | M4: Dash and Custom strokes have a Dash cap (None, Round, Square) in the advanced stroke settings and `node.set {dashCap}`, on both ends of every dash on the canvas and in PNG, PDF and SVG. Unset, dashes keep taking the end point caps, so older files look the same; the saved file writes it only when set. Numbers unchanged | | 2026-10-30 | minor | `.fig` import: a connector's label without a saved snapshot was skipped; now the connector imports as a frame holding its line and the label, as a connector with a saved snapshot does, the label centred on the line's middle by length (in a 200-wide box, laid out again with the fonts at hand), in the connector's fill paints or black. Its background isn't known without the snapshot and is left out. Not checked on real files. Test: `fig::tests::tail::a_labelled_connector_without_a_snapshot_keeps_its_label` | diff --git a/docs/mcp.md b/docs/mcp.md index b5e49e749..9507ff73f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -373,7 +373,7 @@ a later step can use `{"$step": 0, "field": "ids/0"}` to target the first match. | `inspect_document {depth, allPages, fields?, outline?}` | The document as JSON (its `id`, layer tree, styles, selection, history); `fields` selects layer properties, `outline: true` gives a compact tree (below). The tools that act on a document also take `document` (see Naming the document below). | | `get_node {id, depth, fields?, outline?}` | One layer as JSON; takes the same `fields` and `outline` options; `pluginData` (`{key: value}`, values as strings) when the layer or page has layer data written with `set_properties {props: {pluginData}}`. | | `select {ids}` | Replace the selection (`[]` deselects). | -| `export {ids, format, scale \| size, path?, bleed?, cropMarks?, bleedMarks?, cmyk?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?, vdCompat?}` | png, jpg, svg, webp, pdf, xml (an Android Vector Drawable: `svgIdAttribute` writes `android:name`, `vdCompat` is `bestEffort` (default: approximated with a warning), `exact` (left out with a warning) or `ignore` (no warnings: overlapping layer opacity dropped, even-odd fills kept as `fillType="evenOdd"`, diamond gradients left out), and the result lists `warnings` for what was changed or left out), or `fig`: the whole file as `.fig`, experimental (see Exporting `.fig` files below); `size` is `2x`, `512w` or `512h`. The image covers what the layers paint (shadows towards their offset, strokes, unclipped children, a mask group's mask; hidden layers take no room; a slice is its area with everything under it, or only the frame or group it sits in while `contentsOnly` is on); `contentsOnly: false` also draws every other layer showing in that area, `includeBoundingBox` exports the layers' own boxes; SVG: `svgOutlineText: false` keeps ``, `svgIdAttribute` writes unique ids from layer names, `svgSimplifyStroke: false` writes every stroke as an outline. The same options go in export settings (`export.add` / `export.update`). PDF: frames one page each (sized in points), anything else together on one page; print options: `bleed` (points or a length like `"3mm"`, `"0.125in"`), crop and bleed marks, CMYK colour (see Exports below), also taken by `file.export`, `file.exportLayers`, `file.exportFramesPdf` and stored per export setting by `export.add` / `export.update`; `run_command` `file.exportFramesPdf {page?, path?}` writes every top-level frame of a page. Without `path`, images come back as MCP image content, SVG as text, XML as text followed by `{format, bytes, warnings}`. Layers' own export settings: `run_command` with `export.add` / `export.update {index, size?, suffix?, format?}` / `export.remove` and `file.exportLayers {ids?, dir?, assets?}` (every layer once per setting, named `.`, a slash in a layer name making a folder; `assets: true` exports the Inspect tab's assets instead: icons as SVG, images as PNG). | +| `export {ids, format, scale \| size, path?, bleed?, cropMarks?, bleedMarks?, cmyk?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?, vdCompat?, quality?}` | png, jpg, svg, webp, pdf, xml (an Android Vector Drawable: `svgIdAttribute` writes `android:name`, `vdCompat` is `bestEffort` (default: approximated with a warning), `exact` (left out with a warning) or `ignore` (no warnings: overlapping layer opacity dropped, even-odd fills kept as `fillType="evenOdd"`, diamond gradients left out), and the result lists `warnings` for what was changed or left out), or `fig`: the whole file as `.fig`, experimental (see Exporting `.fig` files below); `size` is `2x`, `512w` or `512h`; `quality` is the JPG quality, 1 to 100 (default 90). The image covers what the layers paint (shadows towards their offset, strokes, unclipped children, a mask group's mask; hidden layers take no room; a slice is its area with everything under it, or only the frame or group it sits in while `contentsOnly` is on); `contentsOnly: false` also draws every other layer showing in that area, `includeBoundingBox` exports the layers' own boxes; SVG: `svgOutlineText: false` keeps ``, `svgIdAttribute` writes unique ids from layer names, `svgSimplifyStroke: false` writes every stroke as an outline. The same options go in export settings (`export.add` / `export.update`). PDF: frames one page each (sized in points), anything else together on one page; print options: `bleed` (points or a length like `"3mm"`, `"0.125in"`), crop and bleed marks, CMYK colour (see Exports below), also taken by `file.export`, `file.exportLayers`, `file.exportFramesPdf` and stored per export setting by `export.add` / `export.update`; `run_command` `file.exportFramesPdf {page?, path?}` writes every top-level frame of a page. Without `path`, images come back as MCP image content, SVG as text, XML as text followed by `{format, bytes, warnings}`. Layers' own export settings: `run_command` with `export.add` / `export.update {index, size?, suffix?, format?}` / `export.remove` and `file.exportLayers {ids?, dir?, assets?}` (every layer once per setting, named `.`, a slash in a layer name making a folder; `assets: true` exports the Inspect tab's assets instead: icons as SVG, images as PNG). | | `screenshot {ids?, scale?, maxSize?, window?}` | PNG of the page (with background) or of layers, scaled to fit `maxSize` (default 1600). `window: true` captures the app window in front when attached, a window id (see Windows below) that window. | | `open_file {path, variables?, reload?}` / `save_file {path?}` | Open `.figcraft`, `.json` (FigCraft JSON or REST API file JSON with its variables, see below), `.fig` or FigJam `.jam` (best effort, see below), `.sketch` (Sketch 48 and newer, best effort), `.svg` (see Importing SVG below) or an image; save as `.figcraft`. `open_file` returns `opened` (for a file opened anew, a `summary` of `name`, `pages`, `layers` and `images`, and `warnings` or `repairs`; a file that was already open gives its `index`, with `stale: true` when the file changed on disk since that tab read or saved it; `reload: true` reads it again into the tab, refused while the tab has unsaved changes) and the document's `document` outline. | | `undo` / `redo` | Step through history. | From 19af9c9c3be813fca2715946c34f9aad631dee0c Mon Sep 17 00:00:00 2001 From: JonRC Date: Thu, 5 Nov 2026 00:41:46 -0300 Subject: [PATCH 10/18] M6: test that a bad JPG quality is refused for every format (#2004) As vdCompat, bleed and the SVG flags are: PNG, WebP, SVG and a format taken from the path refuse quality 0 and write nothing, and a quality kept on a PNG setting is used once the setting becomes a JPG. --- crates/engine/src/jpg_export_tests.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/crates/engine/src/jpg_export_tests.rs b/crates/engine/src/jpg_export_tests.rs index 37e85af8b..9d3c823a4 100644 --- a/crates/engine/src/jpg_export_tests.rs +++ b/crates/engine/src/jpg_export_tests.rs @@ -76,6 +76,28 @@ fn bad_qualities_are_refused() { assert_eq!(s.doc().unwrap().node(r).unwrap().exports[0].quality, 40); } +#[test] +fn a_bad_quality_is_refused_whatever_the_format() { + // As `vdCompat`, `bleed` and the SVG flags are: a value is checked for every format, so a + // setting that becomes a JPG later never holds one the encoder can't take. + let mut s = Session::new(); + let r = gradient_rect(&mut s); + assert!(s.execute("file.export", &json!({"ids": [r], "format": "png", "vdCompat": "junk"})).is_err()); + let png = std::env::temp_dir().join(format!("figcraft-bad-quality-{}.png", std::process::id())); + for mut p in [json!({"format": "png"}), json!({"format": "webp"}), json!({"format": "svg"}), json!({"path": png.to_string_lossy()})] { + p["ids"] = json!([r]); + p["quality"] = json!(0); + let e = s.execute("file.export", &p).unwrap_err(); + assert!(e.to_string().contains("quality: a whole number from 1 to 100"), "{p}: {e}"); + } + assert!(!png.exists(), "the refused export wrote nothing"); + // A valid quality on a PNG setting is kept, and used once the setting is a JPG. + s.execute("export.add", &json!({"ids": [r], "setting": {"format": "png", "quality": 40}})).unwrap(); + s.execute("export.update", &json!({"ids": [r], "index": 0, "format": "jpg"})).unwrap(); + let files = s.execute("file.exportLayers", &json!({"ids": [r]})).unwrap()["files"].as_array().unwrap().clone(); + assert_eq!(bytes(&files[0]), jpg(&mut s, r, json!({"quality": 40}))); +} + #[test] fn export_settings_keep_their_quality() { let mut s = Session::new(); From cd5c9aeb09408fcb7ce50590fb9ce39b59e4cbe5 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Thu, 5 Nov 2026 03:23:07 -0500 Subject: [PATCH 11/18] =?UTF-8?q?M4:=20a=20paragraph's=20base=20direction?= =?UTF-8?q?=20can=20be=20forced:=20Text=20=E2=96=B8=20Text=20direction=20(?= =?UTF-8?q?#1830)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Direction came from the letters only; Add right-to-left text says controls override it per paragraph (Text > Text direction, Left to right or Right to left). `TextDirection` on `ParaList`, `Bidi::of` with a base level per paragraph, `text.direction` and the three menu commands (the edited paragraphs while editing), `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect `textDirection` / `lineDirections`, the CSS `direction`, and the `.fig` lines' `sourceDirectionality` in and out. Co-Authored-By: Claude Fable 5.1 --- ROADMAP.md | 1 + crates/doc/src/text.rs | 29 +++++-- crates/engine/src/automation/inspect.rs | 12 +++ crates/engine/src/catalog.rs | 7 +- crates/engine/src/cmd/props.rs | 8 +- crates/engine/src/cmd/text.rs | 31 ++++++- crates/engine/src/dev/codegen/css.rs | 7 ++ crates/engine/src/text_behaviour_tests.rs | 51 ++++++++++++ crates/engine/src/text_tail_tests.rs | 13 ++- crates/format/src/fig/export.rs | 4 +- crates/format/src/fig/export/shape.rs | 11 ++- crates/format/src/fig/map/text.rs | 12 +++ crates/format/src/fig/tests.rs | 3 + crates/format/src/fig/text_direction_tests.rs | 83 +++++++++++++++++++ crates/format/src/fig/text_list_tests.rs | 2 +- crates/format/src/rest/text.rs | 20 +++-- crates/text/src/behaviour_tests.rs | 2 +- crates/text/src/layout.rs | 46 +++++++--- crates/text/src/tail_tests.rs | 4 +- crates/text/src/tests.rs | 26 +++++- crates/ui-egui/locales/en.toml | 4 + crates/ui-egui/src/panels/typography.rs | 2 +- .../ui-egui/src/panels/typography/fields.rs | 30 ++++++- docs/localization-parity.md | 4 +- docs/ui-parity.md | 4 +- 25 files changed, 365 insertions(+), 51 deletions(-) create mode 100644 crates/format/src/fig/text_direction_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 410705919..c5ca92f35 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-11-05** · M4, #1830: a paragraph's base direction can be forced (Text ▸ Text direction: Auto, Left to right, Right to left) as Add right-to-left text says ("controls are available to override text direction per paragraph"). `ParaList::direction`, `text.direction {direction, ids?}` and the three menu commands (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, `textDirection` / `lineDirections` in inspect, the CSS `direction`, and the `.fig` lines' `sourceDirectionality` in and out. Layout gives each paragraph its base level (`Bidi::of` per paragraph). Tests: `text_behaviour_tests::text_direction_is_forced_per_paragraph`, `fig::text_direction_tests`. - **2026-10-30**: M8, #1125: damaged `trak` size-table offsets are checked instead of wrapping on 32-bit targets, and an incomplete normal track is dropped entirely, never returned in part. Font indexing makes its existing bounds explicit, preserving later faces and sparse name-table reads. Synthetic regression tests in `fontdb_tests.rs`. Numbers unchanged. - **2026-10-30** · Text style dedupe is O(spans) (M8, #1102): `shape_text` deduped spans into styles by a scan over the styles already collected — O(spans × styles), so a styled text's dedupe grew with the square of its runs (a synthetic 50k-run text with 100 alternating styles spent 7.7 ms in it). Now the last two styles again (spans run in blocks), then buckets keyed by a cheap fingerprint (family and style bytes, size bits with −0.0 folded, the enums, fill count and solid colours), each bucket resolved with the full equality: the result is the scan's, never worse than it, and O(spans) when names and sizes differ (the same synthetic text: 2.6 ms). The font-db write lock is untouched: `find()` loads faces, which needs the write. `layout_dedupe_tests`. Numbers unchanged. - **2026-10-29** · M10: correct nine German labels in `de.toml`: gradient stops use Verlaufsstopp, OpenType ss01–ss05 use Stilsatz, and the Home-screen preference names the Startbildschirm consistently in the UI and Actions palette. This is a partial terminology pass; German remains a draft awaiting a full native-speaker review and maintainer approval. Numbers unchanged. diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index ba196edb3..0707f4201 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -259,10 +259,24 @@ pub enum ListStyle { Numbered, } +/// A paragraph's base direction: by its letters, or forced ("controls are available to override +/// text direction per paragraph", Add right-to-left text). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum TextDirection { + /// The first letter with a direction decides (Unicode rules P2 and P3). + #[default] + Auto, + /// Left to right, whatever the letters. + Ltr, + /// Right to left, whatever the letters. + Rtl, +} + /// Deepest list nesting (levels 0..=MAX_LIST_INDENT). pub const MAX_LIST_INDENT: u8 = 8; -/// One paragraph's list settings. +/// One paragraph's list settings and base direction. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(default)] pub struct ParaList { @@ -276,6 +290,9 @@ pub struct ParaList { /// (items that go on counting ignore it; other paragraphs are always 0). #[serde(skip_serializing_if = "crate::node::is_default")] pub start_offset: u32, + /// The paragraph's base direction (Text ▸ Text direction); `Auto` follows its letters. + #[serde(skip_serializing_if = "crate::node::is_default")] + pub direction: TextDirection, } /// Character style. In runs, every field is an override; `None` means "inherit the base". @@ -991,20 +1008,20 @@ mod case_tests { // Files written before start numbers load with none, and write the same bytes. let old = r#"{"list":"numbered","indent":1}"#; let pl: ParaList = serde_json::from_str(old).unwrap(); - assert_eq!(pl, ParaList { list: N, indent: 1, start_offset: 0 }); + assert_eq!(pl, ParaList { list: N, indent: 1, start_offset: 0, ..ParaList::default() }); assert_eq!(serde_json::to_string(&pl).unwrap(), old); // One numbered paragraph with a start keeps it (it doesn't fold into `list`). let mut t = TextData { characters: "Next".into(), ..Default::default() }; - t.set_para_lists(0, 0, |p| *p = ParaList { list: N, indent: 0, start_offset: 1 }); - assert_eq!(t.para_list(0), ParaList { list: N, indent: 0, start_offset: 1 }); + t.set_para_lists(0, 0, |p| *p = ParaList { list: N, indent: 0, start_offset: 1, ..ParaList::default() }); + assert_eq!(t.para_list(0), ParaList { list: N, indent: 0, start_offset: 1, ..ParaList::default() }); // Enter copies it to the new item; different starts don't make the list mixed. t.replace_range(4, 4, "\nMore"); assert_eq!(t.para_list(1).start_offset, 1); t.set_para_lists(5, 5, |p| p.start_offset = 0); assert!(t.common_para_list(0, t.char_count()).is_some_and(|p| p.list == N)); // Bullets have no number, so no start. - t.set_para_lists(0, 0, |p| *p = ParaList { list: B, indent: 1, start_offset: 4 }); - assert_eq!(t.para_list(0), ParaList { list: B, indent: 1, start_offset: 0 }); + t.set_para_lists(0, 0, |p| *p = ParaList { list: B, indent: 1, start_offset: 4, ..ParaList::default() }); + assert_eq!(t.para_list(0), ParaList { list: B, indent: 1, start_offset: 0, ..ParaList::default() }); // A plain paragraph has none. t.set_para_lists(0, 0, |p| p.list = P); assert_eq!(t.para_list(0), ParaList::default()); diff --git a/crates/engine/src/automation/inspect.rs b/crates/engine/src/automation/inspect.rs index 871dbea6f..324635071 100644 --- a/crates/engine/src/automation/inspect.rs +++ b/crates/engine/src/automation/inspect.rs @@ -53,6 +53,7 @@ pub const NODE_KEYS: &[&str] = &[ "layout", "leadingTrim", "letterSpacing", + "lineDirections", "lineHeight", "lineIndentations", "lineTypes", @@ -96,6 +97,7 @@ pub const NODE_KEYS: &[&str] = &[ "textDecorationSkipInk", "textDecorationStyle", "textDecorationThickness", + "textDirection", "textPath", "textStyleRanges", "type", @@ -432,6 +434,16 @@ fn text_props(t: &TextData, o: &mut serde_json::Map, options: &In insert_prop(o, options, "listStartOffsets", || t.paragraphs.iter().map(|p| json!(p.start_offset)).collect()); } } + // The paragraphs' base direction: one for the layer when they agree, else each one's (Auto + // throughout says nothing). + let dirs: Vec = t.paragraphs.iter().map(|p| p.direction).collect(); + if let Some(first) = dirs.first().copied().filter(|d| *d != figcraft_doc::TextDirection::Auto) + && dirs.iter().all(|d| *d == first) + { + insert_prop(o, options, "textDirection", || json!(first)); + } else if dirs.iter().any(|d| *d != figcraft_doc::TextDirection::Auto) { + insert_prop(o, options, "lineDirections", || dirs.iter().map(|d| json!(d)).collect()); + } if t.list_spacing != 0.0 { insert_prop(o, options, "listSpacing", || json!(t.list_spacing)); } diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 62236b8e7..a8a18b38c 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -216,6 +216,11 @@ pub static MENU: &[MenuItem] = &[ m!("Text/Case", "Uppercase", "text.caseUpper"), m!("Text/Case", "Lowercase", "text.caseLower"), m!("Text/Case", "Title case", "text.caseTitle"), + // "Go to Text > Text direction. Select Left to right or Right to left." (Add right-to-left + // text); Auto, the way back to detection, is FigCraft's own (in `OWN`). + m!("Text/Text direction", "Auto", "text.directionAuto"), + m!("Text/Text direction", "Left to right", "text.directionLtr"), + m!("Text/Text direction", "Right to left", "text.directionRtl"), m!("Text", SEP, ""), m!("Text", "Missing fonts…", "text.missingFonts"), // Arrange @@ -292,7 +297,7 @@ pub fn items() -> impl Iterator { } /// Items of FigCraft's own that the parity count leaves out (as it does the `collab.` ones). -const OWN: [&str; 5] = ["file.newFromTemplate", "file.newBoard", "help.gettingStarted", "text.missingFonts", "image.crop"]; +const OWN: [&str; 6] = ["file.newFromTemplate", "file.newBoard", "help.gettingStarted", "text.missingFonts", "image.crop", "text.directionAuto"]; /// Coverage: (implemented, total, missing ids). `extra` lists ids implemented by the UI layer. pub fn parity(extra: &[&str]) -> (usize, usize, Vec<&'static str>) { diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index abd380cfc..de47105df 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -956,6 +956,12 @@ fn apply_text(cmd: &str, doc: &mut Document, id: NodeId, props: &Map { + let d: figcraft_doc::TextDirection = enum_of(v).ok_or_else(|| bad(cmd, "textDirection: auto|ltr|rtl"))?; + let t = text_mut(cmd, doc.get_mut(id)?)?; + let (start, end) = range_of(props, t.char_count()); + t.set_para_lists(start, end, |p| p.direction = d); + } "listSpacing" => text_mut(cmd, doc.get_mut(id)?)?.list_spacing = f64_of(v).filter(|s| s.is_finite()).unwrap_or(0.0).clamp(0.0, 10_000.0), "hangingPunctuation" | "hangingList" => { let on = v.as_bool().ok_or_else(|| bad(cmd, format!("`{k}` must be true or false")))?; @@ -1210,7 +1216,7 @@ pub fn override_group(k: &str) -> &'static str { "name" => "name", "characters" | "text" => "characters", "features" | "fontFeatures" | "openTypeFeatures" | "listStyle" | "indentation" | "listSpacing" | "maxLines" | "variationSettings" - | "hangingPunctuation" | "hangingList" | "wrapStyle" | "textWrap" => "textStyle", + | "hangingPunctuation" | "hangingList" | "wrapStyle" | "textWrap" | "textDirection" | "direction" => "textStyle", "cornerRadius" | "cornerSmoothing" | "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => "corners", "width" | "height" | "w" | "h" => "size", "sizingH" | "sizingV" | "layoutSizingHorizontal" | "layoutSizingVertical" | "minWidth" | "maxWidth" | "minHeight" | "maxHeight" => "resizing", diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 7914f259e..290faf913 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -1,8 +1,8 @@ //! Text menu and text editing commands. use figcraft_doc::{ - LetterSpacing, LineHeight, ListStyle, NodeId, NodeKind, TextAlignH, TextAutoResize, TextCase, TextData, TextDecoration, TextPath, TextStyle, - VariableId, + LetterSpacing, LineHeight, ListStyle, NodeId, NodeKind, TextAlignH, TextAutoResize, TextCase, TextData, TextDecoration, TextDirection, TextPath, + TextStyle, VariableId, }; use serde_json::{Map, Value, json}; @@ -91,6 +91,13 @@ fn set_list(s: &mut Session, p: &Value, l: ListStyle, toggle: bool) -> Result Result { + para_range(s, p, |t, a, b| t.set_para_lists(a, b, |pl| pl.direction = d)) +} + /// Whether every targeted char has `pred` (to decide toggle direction). fn all_have(s: &Session, p: &Value, pred: impl Fn(&TextStyle) -> bool) -> bool { let Ok(doc) = s.doc() else { return false }; @@ -319,6 +326,26 @@ pub fn specs() -> Vec { para(s, p, |t| t.wrap_style = w) } ), + spec!( + "text.direction", + "Text direction", + "{direction: auto|ltr|rtl, ids?} the base direction of the paragraphs (while editing: the selected ones): auto goes by the first letter with a direction, ltr runs them left to right and rtl right to left whatever their letters (Text ▸ Text direction)", + true, + |s, p| { + let d: TextDirection = + get(p, "direction").and_then(super::props::enum_of).ok_or_else(|| bad("text.direction", "`direction`: auto|ltr|rtl"))?; + set_direction(s, p, d) + } + ), + spec!("text.directionAuto", "Text direction: auto", "{ids?} while editing: the selected paragraphs", true, |s, p| { + set_direction(s, p, TextDirection::Auto) + }), + spec!("text.directionLtr", "Text direction: left to right", "{ids?} while editing: the selected paragraphs", true, |s, p| { + set_direction(s, p, TextDirection::Ltr) + }), + spec!("text.directionRtl", "Text direction: right to left", "{ids?} while editing: the selected paragraphs", true, |s, p| { + set_direction(s, p, TextDirection::Rtl) + }), spec!("text.paragraphIndent", "Paragraph indent", "{indent (px), ids?}", true, |s, p| { let v = finite(req_num("text.paragraphIndent", p, "indent")?).clamp(0.0, 10_000.0); para(s, p, |t| t.paragraph_indent = v) diff --git a/crates/engine/src/dev/codegen/css.rs b/crates/engine/src/dev/codegen/css.rs index 2395c7873..c772367fd 100644 --- a/crates/engine/src/dev/codegen/css.rs +++ b/crates/engine/src/dev/codegen/css.rs @@ -307,6 +307,13 @@ fn text_decls(doc: &Document, n: &Node, tx: &figcraft_doc::TextData, t: &mut Tok figcraft_doc::WrapStyle::Balance => put("text-wrap", "balance".into()), figcraft_doc::WrapStyle::Pretty => put("text-wrap", "pretty".into()), } + // A base direction forced on every paragraph (CSS has one per element). + let dir = tx.paragraphs.first().map(|p| p.direction).filter(|d| tx.paragraphs.iter().all(|p| p.direction == *d)); + match dir { + Some(figcraft_doc::TextDirection::Ltr) => put("direction", "ltr".into()), + Some(figcraft_doc::TextDirection::Rtl) => put("direction", "rtl".into()), + _ => {} + } match s.case { TextCase::Original => {} TextCase::Upper => put("text-transform", "uppercase".into()), diff --git a/crates/engine/src/text_behaviour_tests.rs b/crates/engine/src/text_behaviour_tests.rs index 1e700c688..6db19a1cc 100644 --- a/crates/engine/src/text_behaviour_tests.rs +++ b/crates/engine/src/text_behaviour_tests.rs @@ -371,6 +371,57 @@ fn max_lines_on_auto_height_text_hugs_that_many_lines() { assert_eq!(resize_mode(&s, fixed), TextAutoResize::Truncate); } +#[test] +fn text_direction_is_forced_per_paragraph() { + // Add right-to-left text: "By default, Figma automatically handles text direction based on + // language detection. However, controls are available to override text direction per + // paragraph" ("Go to Text > Text direction. Select Left to right or Right to left."). + let font = test_mono_font(); + let mut s = Session::new(); + let t = text(&mut s, "abc def\nghi\njkl", json!({"fontFamily": font, "fontSize": 16, "lineHeight": 24})); + let rtl = |s: &Session| -> Vec { + let (l, _) = figcraft_text::layout_node(s.doc().unwrap().node(t).unwrap()).unwrap(); + l.lines.iter().map(|l| l.rtl).collect() + }; + let inspect = |s: &Session| crate::inspect::node_json(s.doc().unwrap(), t, 0); + assert_eq!(rtl(&s), [false, false, false], "Latin letters read left to right"); + assert!(inspect(&s).get("textDirection").is_none() && inspect(&s).get("lineDirections").is_none()); + // The layer's paragraphs at once, by the command or `node.set`; inspect reports the one + // direction they share, and Dev Mode's CSS has it. + s.execute("text.direction", &json!({"ids": [t], "direction": "rtl"})).unwrap(); + assert_eq!(rtl(&s), [true, true, true], "forced right to left whatever the letters"); + assert_eq!(inspect(&s)["textDirection"], json!("rtl")); + let css = |s: &mut Session| s.execute("inspect.code", &json!({"id": t})).unwrap()["code"].as_str().unwrap().to_string(); + assert!(css(&mut s).contains("direction: rtl;"), "{}", css(&mut s)); + s.execute("node.set", &json!({"id": t, "props": {"textDirection": "ltr"}})).unwrap(); + assert_eq!(rtl(&s), [false, false, false]); + assert_eq!(inspect(&s)["textDirection"], json!("ltr")); + assert!(css(&mut s).contains("direction: ltr;")); + s.execute("text.directionAuto", &json!({"ids": [t]})).unwrap(); + assert!(inspect(&s).get("textDirection").is_none(), "auto throughout says nothing"); + assert!(!css(&mut s).contains("direction:")); + // While editing, the caret's paragraph alone (the menu's Right to left); inspect lists each + // paragraph's direction when they differ, and the CSS stays quiet. + s.execute("text.edit.begin", &json!({"id": t, "caret": 9})).unwrap(); + s.execute("text.directionRtl", &json!({})).unwrap(); + s.execute("text.edit.end", &json!({})).unwrap(); + assert_eq!(rtl(&s), [false, true, false], "the second paragraph only"); + assert_eq!(inspect(&s)["lineDirections"], json!(["auto", "rtl", "auto"])); + assert!(!css(&mut s).contains("direction:")); + // Enter inside a forced paragraph keeps the direction for the new one, as list settings carry. + s.execute("text.edit.begin", &json!({"id": t, "caret": 10})).unwrap(); + s.execute("text.edit.insert", &json!({"text": "\n"})).unwrap(); + s.execute("text.edit.end", &json!({})).unwrap(); + assert_eq!(rtl(&s), [false, true, true, false]); + // A direction the document has words for; junk is refused. + s.execute("node.set", &json!({"id": t, "props": {"characters": "\u{05E9}\u{05DC}\u{05D5}\u{05DD} abc", "textDirection": "auto"}})).unwrap(); + assert_eq!(rtl(&s), [true], "Hebrew first: right to left by detection"); + s.execute("node.set", &json!({"id": t, "props": {"textDirection": "ltr"}})).unwrap(); + assert_eq!(rtl(&s), [false], "forced left to right"); + assert!(s.execute("node.set", &json!({"id": t, "props": {"textDirection": "sideways"}})).is_err()); + assert!(s.execute("text.direction", &json!({"ids": [t], "direction": "up"})).is_err()); +} + #[test] fn balance_and_pretty_wrap_styles_break_lines_as_documented() { // Explore text properties: "Auto: Figma fits as many words as possible on each line before diff --git a/crates/engine/src/text_tail_tests.rs b/crates/engine/src/text_tail_tests.rs index 8e399ba3a..ccd8328ff 100644 --- a/crates/engine/src/text_tail_tests.rs +++ b/crates/engine/src/text_tail_tests.rs @@ -64,7 +64,7 @@ fn a_list_with_a_start_number_toggles_off() { let mut s = Session::new(); let t = text(&mut s, "two\nthree\nfour"); if let figcraft_doc::NodeKind::Text(td) = &mut s.doc_mut().unwrap().get_mut(t).unwrap().kind { - let n = |start_offset| ParaList { list: N, indent: 0, start_offset }; + let n = |start_offset| ParaList { list: N, indent: 0, start_offset, ..ParaList::default() }; td.paragraphs = vec![n(1), n(0), n(0)]; } let j = crate::inspect::node_json(s.doc().unwrap(), t, 0); @@ -157,7 +157,8 @@ fn list_keys_at_list_items() { for (chars, before, caret, k, shift, after, want, why) in cases { let mut s = Session::new(); let t = text(&mut s, chars); - let ps: Vec = before.iter().map(|(list, indent)| ParaList { list: *list, indent: *indent, start_offset: 0 }).collect(); + let ps: Vec = + before.iter().map(|(list, indent)| ParaList { list: *list, indent: *indent, start_offset: 0, ..ParaList::default() }).collect(); if let figcraft_doc::NodeKind::Text(td) = &mut s.doc_mut().unwrap().get_mut(t).unwrap().kind { td.paragraphs = ps; } @@ -181,7 +182,11 @@ fn paragraph_lists_follow_edits() { // A paragraph's list stays with it as text is typed, pasted and deleted: new paragraphs copy // the one they split from; joined paragraphs keep the first one's. let mut t = TextData::new("ab\ncd\nef"); - t.paragraphs = vec![ParaList { list: B, indent: 1, start_offset: 0 }, ParaList::default(), ParaList { list: N, indent: 0, start_offset: 0 }]; + t.paragraphs = vec![ + ParaList { list: B, indent: 1, start_offset: 0, ..ParaList::default() }, + ParaList::default(), + ParaList { list: N, indent: 0, start_offset: 0, ..ParaList::default() }, + ]; let get = |t: &TextData| (0..t.paragraph_count()).map(|p| (t.para_list(p).list, t.para_list(p).indent)).collect::>(); t.replace_range(1, 1, "x\ny"); assert_eq!(get(&t), [(B, 1), (B, 1), (P, 0), (N, 0)], "a pasted newline: a new item like it"); @@ -196,7 +201,7 @@ fn paragraph_lists_follow_edits() { (seed >> 33) as usize % n.max(1) }; let mut t = TextData::new("a\nb\nc\nd"); - t.paragraphs = vec![ParaList { list: B, indent: 2, start_offset: 0 }; 4]; + t.paragraphs = vec![ParaList { list: B, indent: 2, start_offset: 0, ..ParaList::default() }; 4]; for _ in 0..500 { let n = t.char_count(); let (a, b) = (rnd(n + 2), rnd(n + 2)); diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index 9e2c4b364..d42679324 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -27,8 +27,8 @@ use figcraft_doc::proto::{ use figcraft_doc::{ AutoLayout, BoolOp, ContentAlign, CrossAlign, Document, EffectKind, ExportSize, GridAlign, GridKind, GridTrack, HConstraint, LayoutMode, LetterSpacing, LineHeight, ListStyle, MainAlign, MaskType, Node, NodeId, NodeKind, NoiseMode, PropDef, Sizing, StrokeAlign, StrokeCap, - StrokeJoin, StyleKind, TextAlignH, TextAlignV, TextAutoResize, TextCase, TextData, TextDecoration, TextStyle, VConstraint, VariableType, - VariableValue, VectorNetwork, WindingRule, + StrokeJoin, StyleKind, TextAlignH, TextAlignV, TextAutoResize, TextCase, TextData, TextDecoration, TextDirection, TextStyle, VConstraint, + VariableType, VariableValue, VectorNetwork, WindingRule, }; use serde_json::Value; use sha1::Digest; diff --git a/crates/format/src/fig/export/shape.rs b/crates/format/src/fig/export/shape.rs index e4e2cae6b..69228a0cc 100644 --- a/crates/format/src/fig/export/shape.rs +++ b/crates/format/src/fig/export/shape.rs @@ -1,8 +1,8 @@ use super::common::{style_fields, unsupported_decoration_look}; use super::{ Affine, BezPath, Bound, Exporter, Fields, Kv, ListStyle, MAX_CANVAS, MAX_CHARS, MAX_NETWORK, Node, NodeKind, Paint, StrokeAlign, StrokeCap, - StrokeJoin, TextAlignH, TextAlignV, TextAutoResize, TextData, TextStyle, VectorNetwork, WindingRule, arr, b, cap_name, e, finite, fl, int, - join_name, network, o, path_blob, s, u, xy, + StrokeJoin, TextAlignH, TextAlignV, TextAutoResize, TextData, TextDirection, TextStyle, VectorNetwork, WindingRule, arr, b, cap_name, e, finite, + fl, int, join_name, network, o, path_blob, s, u, xy, }; impl<'d> Exporter<'d> { @@ -128,11 +128,16 @@ impl<'d> Exporter<'d> { let listed = *l != ListStyle::None; let begins = first(i, l); let start = starts.get(i).copied().unwrap_or(0); + let direction = match td.para_list(i).direction { + TextDirection::Auto => "AUTO", + TextDirection::Ltr => "LTR", + TextDirection::Rtl => "RTL", + }; o(vec![ ("lineType", e(ty)), ("styleId", int(0)), ("indentationLevel", int(i64::from(listed))), - ("sourceDirectionality", e("AUTO")), + ("sourceDirectionality", e(direction)), ("listStartOffset", int(i64::from(start))), ("isFirstLineOfList", b(begins)), ]) diff --git a/crates/format/src/fig/map/text.rs b/crates/format/src/fig/map/text.rs index 13bed7440..0249b1086 100644 --- a/crates/format/src/fig/map/text.rs +++ b/crates/format/src/fig/map/text.rs @@ -130,6 +130,18 @@ impl<'a> Mapper<'a> { if lines.iter().any(|l| *l != "NONE") { o.insert("lineTypes".into(), json!(lines)); } + // A forced base direction per paragraph (`sourceDirectionality`; not a REST field). + let dirs: Vec<&str> = raw + .iter() + .map(|l| match l.get("sourceDirectionality").and_then(Kv::str) { + Some("LTR") => "LTR", + Some("RTL") => "RTL", + _ => "AUTO", + }) + .collect(); + if dirs.iter().any(|d| *d != "AUTO") { + o.insert("lineDirections".into(), json!(dirs)); + } // Where a numbered list starts counting, less one (not a REST field). let starts: Vec = raw.iter().map(|l| num(l, "listStartOffset").filter(|n| n.is_finite()).map_or(0, |n| n.clamp(0.0, f64::from(u32::MAX)) as u32)).collect(); diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 2d4a1f7d6..8833727ee 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -1491,6 +1491,9 @@ mod image_filters; #[path = "text_list_tests.rs"] mod text_list; +#[path = "text_direction_tests.rs"] +mod text_direction; + /// `.fig` files in the folder `FIGCRAFT_FIG_SAMPLES` names (none when it's unset). pub(super) fn samples() -> Vec { let Some(dir) = std::env::var_os("FIGCRAFT_FIG_SAMPLES") else { return Vec::new() }; diff --git a/crates/format/src/fig/text_direction_tests.rs b/crates/format/src/fig/text_direction_tests.rs new file mode 100644 index 000000000..a3647946d --- /dev/null +++ b/crates/format/src/fig/text_direction_tests.rs @@ -0,0 +1,83 @@ +//! A paragraph's forced base direction (#1830): each text line's `sourceDirectionality` (AUTO, +//! LTR or RTL) comes in as the paragraph's direction and goes back out. Fixtures built in code with +//! the parent module's helpers. + +use figcraft_doc::{NodeKind, TextDirection}; + +use super::*; + +const DIRECTION_SCHEMA: &str = " +enum NodeType NONE DOCUMENT CANVAS TEXT +enum LineType PLAIN ORDERED_LIST UNORDERED_LIST +enum SourceDirectionality AUTO LTR RTL +struct GUID uint sessionID, uint localID +struct Vector float x, float y +struct Matrix float m00, float m01, float m02, float m10, float m11, float m12 +message ParentIndex GUID guid, string position +message TextLineData LineType lineType, int indentationLevel, SourceDirectionality sourceDirectionality, int listStartOffset, bool isFirstLineOfList +message TextData string characters, TextLineData[] lines +message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, float fontSize, TextData textData +message Message NodeChange[] nodeChanges +"; + +/// A plain line with a base direction. +fn line(direction: &'static str) -> Kv<'static> { + o(vec![ + ("lineType", e("PLAIN")), + ("indentationLevel", Kv::Int(0)), + ("sourceDirectionality", e(direction)), + ("listStartOffset", Kv::Int(0)), + ("isFirstLineOfList", Kv::Bool(false)), + ]) +} + +fn text_node(id: u64, name: &str, chars: &str, lines: Vec>) -> Kv<'static> { + let pos = ["!", "\"", "#", "$"][id as usize % 4]; + node( + id, + Some((1, pos)), + "TEXT", + name, + vec![ + ("size", xy(200.0, 60.0)), + ("transform", at(0.0, 0.0)), + ("fontSize", f(16.0)), + ("textData", o(vec![("characters", s(chars)), ("lines", arr(lines))])), + ], + ) +} + +fn directions(doc: &figcraft_doc::Document, name: &str) -> Vec { + let NodeKind::Text(t) = &named(doc, name).kind else { panic!("{name} is no text") }; + (0..t.paragraph_count()).map(|p| t.para_list(p).direction).collect() +} + +#[test] +fn a_lines_source_directionality_is_its_paragraphs_direction_both_ways() { + use TextDirection::{Auto as A, Ltr as L, Rtl as R}; + let nodes = vec![ + node(0, None, "DOCUMENT", "Document", vec![]), + node(1, Some((0, "!")), "CANVAS", "Page", vec![]), + text_node(2, "Detected", "abc\ndef", vec![line("AUTO"), line("AUTO")]), + text_node(3, "Forced", "abc\ndef\nghi", vec![line("RTL"), line("AUTO"), line("LTR")]), + ]; + let sc = Schema::parse(DIRECTION_SCHEMA).unwrap(); + let (doc, warnings) = import(&canvas(&sc, &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(directions(&doc, "Detected"), [A, A]); + let NodeKind::Text(t) = &named(&doc, "Detected").kind else { panic!() }; + assert!(t.paragraphs.is_empty(), "AUTO throughout keeps the paragraphs folded"); + assert_eq!(directions(&doc, "Forced"), [R, A, L]); + // Out again: every line says its direction, and a reader gets the same paragraphs back. + let (bytes, _) = super::super::export(&doc, None).unwrap(); + let a = Archive::open(&bytes).unwrap(); + let (schema, message) = decode_canvas(&a.canvas).unwrap(); + let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap(); + let text = msg.get("nodeChanges").unwrap().list().iter().find(|n| n.get("name").and_then(Kv::str) == Some("Forced")).unwrap(); + let lines = text.get("textData").and_then(|d| d.get("lines")).map(Kv::list).unwrap(); + let out: Vec<&str> = lines.iter().map(|l| l.get("sourceDirectionality").and_then(Kv::str).unwrap()).collect(); + assert_eq!(out, ["RTL", "AUTO", "LTR"]); + let (back, _) = import(&bytes, "x").unwrap(); + assert_eq!(directions(&back, "Forced"), [R, A, L]); + assert_eq!(directions(&back, "Detected"), [A, A]); +} diff --git a/crates/format/src/fig/text_list_tests.rs b/crates/format/src/fig/text_list_tests.rs index eca7b0dc5..a2d6702bc 100644 --- a/crates/format/src/fig/text_list_tests.rs +++ b/crates/format/src/fig/text_list_tests.rs @@ -75,7 +75,7 @@ fn the_export_writes_where_a_list_starts() { let page = doc.first_page().unwrap(); use ListStyle::{Bulleted as B, None as P, Numbered as N}; let mut td = figcraft_doc::TextData::new("two\nthree\nplain\nsix\ndot"); - let pl = |list, start_offset| ParaList { list, indent: 0, start_offset }; + let pl = |list, start_offset| ParaList { list, indent: 0, start_offset, ..ParaList::default() }; td.paragraphs = vec![pl(N, 1), pl(N, 0), pl(P, 0), pl(N, 5), pl(B, 4)]; doc.create(page, NodeKind::Text(td), "List", 0.0, 0.0, 200.0, 80.0).unwrap(); let (bytes, _) = super::super::export(&doc, None).unwrap(); diff --git a/crates/format/src/rest/text.rs b/crates/format/src/rest/text.rs index 83055dc10..ca5c51d15 100644 --- a/crates/format/src/rest/text.rs +++ b/crates/format/src/rest/text.rs @@ -5,7 +5,7 @@ use std::collections::BTreeMap; use figcraft_doc::{ DecorationLength, DecorationStyle, LetterSpacing, LineHeight, ListStyle, ParaList, StyleRun, TextAlignH, TextAlignV, TextAutoResize, TextCase, - TextData, TextDecoration, TextPath, TextStyle, + TextData, TextDecoration, TextDirection, TextPath, TextStyle, }; use serde_json::Value; @@ -256,23 +256,27 @@ fn runs(cx: &mut Ctx, v: &Value, td: &TextData) -> Vec { out } -/// Each paragraph's list type, nesting level and start number from `lineTypes`, -/// `lineIndentations` and (from `.fig` files) `listStartOffsets`. +/// Each paragraph's list type, nesting level, start number and base direction from `lineTypes`, +/// `lineIndentations` and (from `.fig` files) `listStartOffsets` and `lineDirections`. fn lists(v: &Value, td: &mut TextData) { let types = list(v, "lineTypes"); let indents = list(v, "lineIndentations"); let starts = list(v, "listStartOffsets"); - td.paragraphs = types - .iter() - .enumerate() - .map(|(i, t)| ParaList { - list: match t.as_str() { + let dirs = list(v, "lineDirections"); + td.paragraphs = (0..types.len().max(dirs.len())) + .map(|i| ParaList { + list: match types.get(i).and_then(Value::as_str) { Some("UNORDERED") => ListStyle::Bulleted, Some("ORDERED") => ListStyle::Numbered, _ => ListStyle::None, }, indent: indents.get(i).and_then(Value::as_f64).filter(|d| d.is_finite()).map_or(0, |d| d.clamp(0.0, 255.0) as u8), start_offset: starts.get(i).and_then(Value::as_f64).filter(|n| n.is_finite()).map_or(0, |n| n.clamp(0.0, f64::from(u32::MAX)) as u32), + direction: match dirs.get(i).and_then(Value::as_str) { + Some("LTR") => TextDirection::Ltr, + Some("RTL") => TextDirection::Rtl, + _ => TextDirection::Auto, + }, }) .collect(); // Clamp the levels and fold one style for all back into the layer's list style. diff --git a/crates/text/src/behaviour_tests.rs b/crates/text/src/behaviour_tests.rs index 439a81fcf..731e639d4 100644 --- a/crates/text/src/behaviour_tests.rs +++ b/crates/text/src/behaviour_tests.rs @@ -109,7 +109,7 @@ fn a_plain_paragraph_at_a_list_level_starts_where_the_items_text_does() { if face().is_none() { return; } - let level = |list: ListStyle, indent: u8| ParaList { list, indent, start_offset: 0 }; + let level = |list: ListStyle, indent: u8| ParaList { list, indent, start_offset: 0, ..ParaList::default() }; let mut t = text("a\nb\nc", TextAutoResize::WidthAndHeight); t.paragraphs = vec![level(ListStyle::Bulleted, 1), level(ListStyle::None, 1), level(ListStyle::None, 0)]; let l = layout(&t, None, None); diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index dd728916b..732e59ec7 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use figcraft_doc::{ DecorationLength, DecorationStyle, LeadingTrim, LetterSpacing, LineHeight, ListStyle, ParaList, TextAlignH, TextAutoResize, TextCase, TextData, - TextDecoration, TextStyle, WrapStyle, color::PaintKind, valid_feature_tag, + TextDecoration, TextDirection, TextStyle, WrapStyle, color::PaintKind, valid_feature_tag, }; use figcraft_geom::{Affine, BezPath, Rect}; use harfrust::{Direction, Feature, ShapeOptions, ShaperInstance, Tag, UnicodeBuffer, Variation}; @@ -272,21 +272,29 @@ pub(crate) struct Bidi { } impl Bidi { - /// None when the text has nothing right to left (the common case costs one scan). - pub(crate) fn of(text: &str) -> Option { - if !text.chars().any(is_rtl_char) { + /// None when the text has nothing right to left and no paragraph forces its direction (the + /// common case costs one scan). `forced`, by paragraph: `Some(true)` runs right to left and + /// `Some(false)` left to right whatever the letters, `None` goes by the first letter with a + /// direction (Text ▸ Text direction, #1830). + pub(crate) fn of(text: &str, forced: &[Option]) -> Option { + if forced.iter().all(Option::is_none) && !text.chars().any(is_rtl_char) { return None; } - let info = unicode_bidi::BidiInfo::new(text, None); - let mut paras = info.paragraphs.iter().peekable(); let n = text.chars().count(); let (mut levels, mut rtl_para) = (Vec::with_capacity(n), Vec::with_capacity(n)); - for (b, _) in text.char_indices() { - while paras.peek().is_some_and(|p| p.range.end <= b) { - paras.next(); + // Paragraph by paragraph (the algorithm starts over at each one), so each can be given its + // base level. + for (p, para) in text.split_inclusive('\n').enumerate() { + let base = forced.get(p).copied().flatten().map(|rtl| if rtl { unicode_bidi::Level::rtl() } else { unicode_bidi::Level::ltr() }); + let info = unicode_bidi::BidiInfo::new(para, base); + let mut paras = info.paragraphs.iter().peekable(); + for (b, _) in para.char_indices() { + while paras.peek().is_some_and(|q| q.range.end <= b) { + paras.next(); + } + levels.push(info.levels.get(b).map(|l| l.number()).unwrap_or(0)); + rtl_para.push(paras.peek().is_some_and(|q| q.level.is_rtl())); } - levels.push(info.levels.get(b).map(|l| l.number()).unwrap_or(0)); - rtl_para.push(paras.peek().is_some_and(|p| p.level.is_rtl())); } Some(Bidi { levels, rtl_para }) } @@ -479,8 +487,20 @@ fn shape_text(text: &TextData) -> ShapedText { .filter_map(|(k, on)| <[u8; 4]>::try_from(k.as_bytes()).ok().map(|t| Feature::new(Tag::new(&t), u32::from(*on), ..))) .collect(); - // Shape each span, split by paragraph, bidi run and fallback face. - let bidi = Bidi::of(&text.characters); + // Shape each span, split by paragraph, bidi run and fallback face. A paragraph's direction is + // its letters' unless forced (Text ▸ Text direction). + let forced: Vec> = if text.paragraphs.is_empty() { + Vec::new() + } else { + (0..text.paragraph_count()) + .map(|p| match text.para_list(p).direction { + TextDirection::Auto => None, + TextDirection::Ltr => Some(false), + TextDirection::Rtl => Some(true), + }) + .collect() + }; + let bidi = Bidi::of(&text.characters, &forced); let level_at = |i: usize| bidi.as_ref().and_then(|b| b.levels.get(i)).copied().unwrap_or(0); // A grapheme cluster (emoji sequence, letter with marks) is shaped with one face. let cluster_start = crate::breaks::grapheme_starts(&text.characters); diff --git a/crates/text/src/tail_tests.rs b/crates/text/src/tail_tests.rs index 805f744d8..035e1912a 100644 --- a/crates/text/src/tail_tests.rs +++ b/crates/text/src/tail_tests.rs @@ -43,11 +43,11 @@ fn starts(l: &TextLayout) -> Vec<(f64, Option)> { } fn pl(list: ListStyle, indent: u8) -> ParaList { - ParaList { list, indent, start_offset: 0 } + ParaList { list, indent, start_offset: 0, ..ParaList::default() } } fn from(list: ListStyle, indent: u8, start_offset: u32) -> ParaList { - ParaList { list, indent, start_offset } + ParaList { list, indent, start_offset, ..ParaList::default() } } #[test] diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 6d341419c..e38963855 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -406,14 +406,36 @@ fn bidi_visual_order_follows_levels() { assert_eq!(crate::layout::visual_order(&[0, 0, 0]), vec![0, 1, 2]); assert!(crate::layout::visual_order(&[]).is_empty()); // Plain left-to-right text skips the analysis. - assert!(crate::layout::Bidi::of("Hello, world 123").is_none()); - let b = crate::layout::Bidi::of("abc \u{5D0}\u{5D1}\u{5D2}\n\u{5D3} x").unwrap(); + assert!(crate::layout::Bidi::of("Hello, world 123", &[]).is_none()); + assert!(crate::layout::Bidi::of("Hello, world 123", &[None, None]).is_none()); + let b = crate::layout::Bidi::of("abc \u{5D0}\u{5D1}\u{5D2}\n\u{5D3} x", &[]).unwrap(); assert_eq!(b.levels.get(..4), Some(&[0, 0, 0, 0][..])); assert!(b.levels.get(4..7).unwrap().iter().all(|l| l % 2 == 1)); // Paragraph direction comes from each paragraph's first strong char. assert!(!b.rtl_para[0] && b.rtl_para[8]); } +/// A paragraph's forced base direction (Text ▸ Text direction, #1830): the base level is given, +/// the letters' own levels still follow the rules, and the other paragraphs keep theirs. +#[test] +fn forced_direction_sets_a_paragraphs_base_level() { + use crate::layout::Bidi; + // Latin letters forced right to left: an embedded left-to-right run (level 2) in a + // right-to-left paragraph; the next paragraph, unforced, stays left to right. + let b = Bidi::of("ab\ncd", &[Some(true), None]).unwrap(); + assert_eq!(b.rtl_para, [true, true, true, false, false]); + assert_eq!((b.levels.get(..2), b.levels.get(3..)), (Some(&[2, 2][..]), Some(&[0, 0][..]))); + // Hebrew forced left to right: a right-to-left run (level 1) in a left-to-right paragraph. + let b = Bidi::of("\u{5D0}\u{5D1}", &[Some(false)]).unwrap(); + assert_eq!(b.rtl_para, [false, false]); + assert_eq!(b.levels, [1, 1]); + // Forcing what the letters already say changes nothing; a paragraph past the forced list is + // detected. + let b = Bidi::of("\u{5D0}\n\u{5D1}", &[Some(true)]).unwrap(); + assert_eq!(b.rtl_para, [true, true, true]); + assert_eq!((b.levels.first(), b.levels.last()), (Some(&1), Some(&1))); +} + /// The x of the glyph for char `i`. fn glyph_x(l: &TextLayout, i: usize) -> f64 { l.glyphs.iter().find(|g| g.char_start == i && g.char_end > i).map(|g| g.x).unwrap() diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 11f0bb4ac..8b83d8c85 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -407,6 +407,10 @@ name = "English" "Decoration" = "Decoration" "Deep select" = "Deep select" "Wrap style" = "Wrap style" +"Text direction" = "Text direction" +"Auto: by the first letter with a direction" = "Auto: by the first letter with a direction" +"Left to right" = "Left to right" +"Right to left" = "Right to left" "Balance" = "Balance" "Pretty" = "Pretty" "Auto: as many words as fit on each line" = "Auto: as many words as fit on each line" diff --git a/crates/ui-egui/src/panels/typography.rs b/crates/ui-egui/src/panels/typography.rs index 0f41fd2e3..a3a5bd66a 100644 --- a/crates/ui-egui/src/panels/typography.rs +++ b/crates/ui-egui/src/panels/typography.rs @@ -10,7 +10,7 @@ use std::sync::Arc; use egui::{Align2, Color32, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use figcraft_doc::{ DecorationLength, Document, LeadingTrim, LetterSpacing, LineHeight, ListStyle, NodeId, NodeKind, TextAlignH, TextAlignV, TextAutoResize, - TextCase, TextData, TextDecoration, TextStyle, VariableType, trim_num, + TextCase, TextData, TextDecoration, TextDirection, TextStyle, VariableType, trim_num, }; use serde_json::{Value, json}; diff --git a/crates/ui-egui/src/panels/typography/fields.rs b/crates/ui-egui/src/panels/typography/fields.rs index 20b9d8e90..c290e9893 100644 --- a/crates/ui-egui/src/panels/typography/fields.rs +++ b/crates/ui-egui/src/panels/typography/fields.rs @@ -4,8 +4,9 @@ use super::picker::{font_picker, remember_family}; use super::spacing::{SpacingField, commit_spacing, parse_letter_spacing, parse_line_height, spacing_field, spacing_input}; use super::{ Arc, DecorationLength, Document, FEATURES, FIELD_H, FieldEdit, FigApp, Icon, Id, Lead, LeadingTrim, ListStyle, MIXED, NodeId, SIZES, Sense, TABS, - TextAlignH, TextAlignV, TextAutoResize, TextCase, TextData, TextDecoration, TextStyle, Tokens, Ui, Value, VariableType, common, field_row, icons, - json, live, menu_item, menu_popup, regular, row, semibold, text_style_params, tr, tr_noop, trf, trim_num, uniform, vec2, widgets, + TextAlignH, TextAlignV, TextAutoResize, TextCase, TextData, TextDecoration, TextDirection, TextStyle, Tokens, Ui, Value, VariableType, common, + field_row, icons, json, live, menu_item, menu_popup, regular, row, semibold, text_style_params, tr, tr_noop, trf, trim_num, uniform, vec2, + widgets, }; /// The font rows: family, style and size, line height and letter spacing. @@ -397,6 +398,31 @@ fn type_settings( let style = ["auto", "balance", "pretty"].get(i).copied().unwrap_or("auto"); app.set_props(ids, json!({"wrapStyle": style})); } + // The paragraphs' base direction, once a right-to-left script is in the text or a direction + // is forced ("controls are available to override text direction per paragraph", Add + // right-to-left text). While editing: the selected paragraphs. + let has_direction = + |t: &TextData| figcraft_text::display::has_rtl(&t.characters) || t.paragraphs.iter().any(|p| p.direction != TextDirection::Auto); + if ids.iter().any(|i| doc.node(*i).and_then(|n| n.kind.text()).is_some_and(has_direction)) { + caption(ui, tr("Text direction")); + let dir = common(doc, ids, |n| { + n.kind.text().and_then(|t| { + let (a, b) = range.filter(|r| r.0 == n.id).map_or((0, t.char_count()), |(_, a, b)| (a, b)); + let (p0, p1) = t.para_span(a, b); + let first = t.para_list(p0).direction; + (p0..=p1).all(|p| t.para_list(p).direction == first).then_some(first as usize) + }) + }) + .flatten(); + let items = [ + (tr("Auto"), tr_noop("Auto: by the first letter with a direction")), + ("→", tr_noop("Left to right")), + ("←", tr_noop("Right to left")), + ]; + if let Some(i) = text_seg(ui, "direction", &items, dir, w) { + app.run("text.direction", json!({"ids": ids, "direction": (["auto", "ltr", "rtl"].get(i).copied().unwrap_or("auto"))})); + } + } let resize = common(doc, ids, |n| n.kind.text().map(|t| t.auto_resize)).flatten(); // Auto-height text with a line limit truncates too. let truncating = common(doc, ids, |n| n.kind.text().map(TextData::truncates)).flatten(); diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 541e7ab90..ac8f1c3f9 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -57,7 +57,8 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v Portuguese take the singular for 0 as well; Japanese, Korean, Chinese and Vietnamese have one form; CLDR's "many" for millions in French, Spanish and Portuguese reads as the plural). - **Script support in documents:** Unicode bidi with Arabic shaping and mirrored brackets in text - layers; right-to-left list markers and indents; right-to-left names in the layers panel, tabs + layers; a paragraph's base direction forced with Text ▸ Text direction (#1830); right-to-left + list markers and indents; right-to-left names in the layers panel, tabs and canvas labels (visual order, joined Arabic); inline IME composition with the candidate window at the caret; system-font fallback for scripts the UI fonts lack. Devanagari shapes through harfrust. No vertical text (Figma has none either). @@ -125,6 +126,7 @@ Hindi and Arabic. Traditional Chinese is not planned (no catalog; its users see | Date | Change | Summary | |---|---|---| +| 2026-11-05 | minor | Documents: a paragraph's base direction can be forced left to right or right to left (Text ▸ Text direction, `text.direction`, `node.set textDirection`, the Type settings control, the `.fig` lines' `sourceDirectionality`), as Add right-to-left text says (#1830); detection stays the default. Four new UI strings (English only; other catalogs fall back). | | 2026-10-30 | minor | Bosnian (`bs`, Bosanski) draft catalog: every UI string (1,906), command label (629) and menu wording (31), machine-drafted in Latin script with ijekavian forms and marked as a draft awaiting native review. Bosnian plurals: one for 1, 21, 31… (not 11), few for 2–4, 22–24… (not 12–14), other for the rest. Grade and hours unchanged (Bosnian is not one of the twelve key languages) | | 2026-10-29 | minor | German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft. Nine existing translations corrected; keys, placeholders, coverage, grade and estimates unchanged | | 2026-10-28 | minor | Arabic draft: 1,857 UI entries, 620 command labels and 31 menu wordings, with count-neutral plurals. AI-assisted, native review pending. Desktop uses the existing Arabic fallback and bidi; web requires an embedded Arabic face. No panel mirroring or parity-grade increase claimed | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index cc0fc9db8..5092a08f2 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (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 Windows and Linux, the two keys the help center gives it; 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 keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (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`; 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 Windows and Linux, the two keys the help center gives it; 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 keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7) 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: @@ -91,6 +91,7 @@ interaction feel not yet compared. | Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | a group past the path operations' caps (250 000 segments, 2 million possible crossings, 4 million segments swept over a fold) draws without its result, with a warning, and Flatten refuses it (#1104) | | Text: point, auto height, fixed; caret, words, lines, IME | done | Backspace or Delete at the start of a list item takes its marker and keeps its level, the paragraph starting where the item's text did ("delete the counter, but keep the same level of indentation", Create bulleted and numbered lists; `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests`); max lines on auto-width text keep it auto width, hugging up to that many lines and growing with its longest line ("Text resizing is set to auto height or auto width", Explore text properties; `text_behaviour_tests::max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines`); Alt+Enter while editing opens the link at the caret, web and mail links only (`text.openLink`; "press ⌥Option Return/Alt Enter to activate a focused link", Accessibility at Figma; `text_link_tests`); the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653) and Up/Down step through it (#352); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; colour emoji draw from their colour tables (#300); IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check | | Text: wrap style (Auto, Balance, Pretty) | done | per text layer (`wrapStyle` on `node.set`, `text.wrapStyle`, the Type settings control under Vertical trim, inspect, CSS `text-wrap: balance` or `pretty`): Auto breaks greedily; Balance narrows each paragraph as far as it goes without another line, so its lines come out close to the same length (what browsers do for `text-wrap: balance`); Pretty keeps lines full and, when the last line is one word, narrows one of the last four lines just enough to give a word to the lines after it (Explore text properties: "Auto: Figma fits as many words as possible on each line before wrapping to the next", "Balance: Figma distributes text as evenly as possible across every line, so each line is close to the same length", "Pretty: Figma keeps each line full, but adjusts the last four lines to avoid an orphan"). Saved line breaks from a file still win. Not yet included: per-paragraph settings, text-style support and the `.fig` import/export mapping (#1827). Test: `text_behaviour_tests::balance_and_pretty_wrap_styles_break_lines_as_documented` | +| Text: direction override (Text ▸ Text direction) | done | per paragraph (`ParaList::direction`: auto, ltr, rtl): Text ▸ Text direction ▸ Auto / Left to right / Right to left and `text.direction {direction, ids?}` set the edited paragraphs while editing, else every paragraph of the selected texts; `node.set textDirection` the layer's (or the chars `start`/`end` name); the Type settings control appears once a right-to-left script is in the text or a direction is forced; inspect reports `textDirection` when the paragraphs agree and `lineDirections` otherwise; Dev Mode's CSS writes `direction: ltr` or `rtl` when every paragraph is forced the same way; `.fig` import and export carry each line's `sourceDirectionality`. Layout gives the paragraph its base level, so a forced right-to-left paragraph of Latin letters starts at the right, mirrors its list gutter and indent and orders its runs right to left, and a forced left-to-right paragraph of Hebrew the other way ("By default, Figma automatically handles text direction based on language detection. However, controls are available to override text direction per paragraph", "Go to Text > Text direction. Select Left to right or Right to left", Add right-to-left text). Not included: a direction in text styles (the article names none). Tests: `text_behaviour_tests::text_direction_is_forced_per_paragraph`, `fig::text_direction_tests` | | 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 | @@ -178,6 +179,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 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 | patch | M8, #1875: a click with the Bend tool on a sharp point between two segments gives it handles along the line between its neighbours, a third of the way to each ("Click on a point or path where you want to add a curve", Edit vector layers); a click on a curved point still makes it sharp, a drag still sets the handles by hand, an endpoint is left alone. Between a handle's end and a point both within reach of the pointer, the nearer one on the page is hit (the handle on a tie), so a short path's point (its handles a few pixels long) can be clicked again. Before, only a drag curved a point, and a short one left two tiny handles in its direction. Tests: `vectoredit::tests::short_handles_leave_their_point_clickable`, `vectoredit::tests::a_bend_click_curves_a_sharp_point_between_its_neighbours`, `bend_tool_smooths_and_sharpens_points`. Numbers unchanged. | | 2026-11-02 | minor | M4, #1827: wrap style on a text layer (`WrapStyle`: Auto, Balance, Pretty): Balance re-breaks each paragraph at the narrowest width that keeps its line count (bisection over the greedy breaker, as browsers balance), Pretty narrows one of the last four lines just enough so the last line isn't one word; `text.wrapStyle`, `node.set wrapStyle` (`textWrap` too), inspect `wrapStyle`, Dev Mode CSS `text-wrap` (Tailwind `text-balance` / `text-pretty`), the Type settings control under Vertical trim. Per-paragraph settings, text-style support and the `.fig` import/export mapping stay open. Test: `text_behaviour_tests::balance_and_pretty_wrap_styles_break_lines_as_documented`. | | 2026-11-02 | patch | M4: Backspace or Delete at the start of a list item takes its marker and keeps its level ("Use Backspace or Delete at the beginning of a list item to delete the counter, but keep the same level of indentation", Create bulleted and numbered lists): a plain paragraph may now carry a list level, and the text layout starts it where that level's item text starts (the gutters stay even when no list is left). Backspace used to drop the level too, and Delete at an item's start deleted the next character. Tab on such a paragraph types a tab, as outside a list. Tests: `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests::a_plain_paragraph_at_a_list_level_starts_where_the_items_text_does` | From c16c3676c27fdf199fea1598c0f16228318af6d5 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Thu, 5 Nov 2026 03:46:28 -0500 Subject: [PATCH 12/18] M4: text direction: CSS keeps Left beside a forced rtl, paragraph arrays read to the paragraph count, the empty last paragraph keeps its direction (#1830) From review: `text-align: left` beside `direction: rtl` (Left is absolute on the canvas); the REST import reads the four parallel paragraph arrays up to the paragraph count, not the 256-entry list limit; `rtl_para` gets an entry for the position after the last char so an empty last paragraph keeps its forced direction; the localization status line. Co-Authored-By: Claude Fable 5.1 --- crates/engine/src/dev/codegen/css.rs | 8 +++++++- crates/engine/src/text_behaviour_tests.rs | 7 +++++++ crates/format/src/rest/text.rs | 11 ++++++----- crates/text/src/layout.rs | 15 +++++++++++++-- crates/text/src/tests.rs | 12 +++++++++--- docs/localization-parity.md | 2 +- docs/ui-parity.md | 2 +- 7 files changed, 44 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/dev/codegen/css.rs b/crates/engine/src/dev/codegen/css.rs index c772367fd..d521674e9 100644 --- a/crates/engine/src/dev/codegen/css.rs +++ b/crates/engine/src/dev/codegen/css.rs @@ -311,7 +311,13 @@ fn text_decls(doc: &Document, n: &Node, tx: &figcraft_doc::TextData, t: &mut Tok let dir = tx.paragraphs.first().map(|p| p.direction).filter(|d| tx.paragraphs.iter().all(|p| p.direction == *d)); match dir { Some(figcraft_doc::TextDirection::Ltr) => put("direction", "ltr".into()), - Some(figcraft_doc::TextDirection::Rtl) => put("direction", "rtl".into()), + Some(figcraft_doc::TextDirection::Rtl) => { + put("direction", "rtl".into()); + // Left alignment is absolute on the canvas; `start` would be the right edge here. + if tx.align_h == TextAlignH::Left { + put("text-align", "left".into()); + } + } _ => {} } match s.case { diff --git a/crates/engine/src/text_behaviour_tests.rs b/crates/engine/src/text_behaviour_tests.rs index 6db19a1cc..025b3bc2b 100644 --- a/crates/engine/src/text_behaviour_tests.rs +++ b/crates/engine/src/text_behaviour_tests.rs @@ -393,6 +393,7 @@ fn text_direction_is_forced_per_paragraph() { assert_eq!(inspect(&s)["textDirection"], json!("rtl")); let css = |s: &mut Session| s.execute("inspect.code", &json!({"id": t})).unwrap()["code"].as_str().unwrap().to_string(); assert!(css(&mut s).contains("direction: rtl;"), "{}", css(&mut s)); + assert!(css(&mut s).contains("text-align: left;"), "Left stays the left edge, which `start` would not be: {}", css(&mut s)); s.execute("node.set", &json!({"id": t, "props": {"textDirection": "ltr"}})).unwrap(); assert_eq!(rtl(&s), [false, false, false]); assert_eq!(inspect(&s)["textDirection"], json!("ltr")); @@ -413,6 +414,12 @@ fn text_direction_is_forced_per_paragraph() { s.execute("text.edit.insert", &json!({"text": "\n"})).unwrap(); s.execute("text.edit.end", &json!({})).unwrap(); assert_eq!(rtl(&s), [false, true, true, false]); + // An empty last paragraph (after a final newline) keeps the direction forced on it. + s.execute("node.set", &json!({"id": t, "props": {"characters": "abc\n", "textDirection": "auto"}})).unwrap(); + s.execute("text.edit.begin", &json!({"id": t, "caret": 4})).unwrap(); + s.execute("text.directionRtl", &json!({})).unwrap(); + s.execute("text.edit.end", &json!({})).unwrap(); + assert_eq!(rtl(&s), [false, true], "the empty last line runs right to left"); // A direction the document has words for; junk is refused. s.execute("node.set", &json!({"id": t, "props": {"characters": "\u{05E9}\u{05DC}\u{05D5}\u{05DD} abc", "textDirection": "auto"}})).unwrap(); assert_eq!(rtl(&s), [true], "Hebrew first: right to left by detection"); diff --git a/crates/format/src/rest/text.rs b/crates/format/src/rest/text.rs index ca5c51d15..391fa4e1c 100644 --- a/crates/format/src/rest/text.rs +++ b/crates/format/src/rest/text.rs @@ -10,7 +10,7 @@ use figcraft_doc::{ use serde_json::Value; use super::Ctx; -use super::props::{flag, len, list, num, paints, text}; +use super::props::{flag, len, num, paints, text}; /// Most characters read from one text node. const MAX_CHARS: usize = 1 << 20; @@ -259,10 +259,11 @@ fn runs(cx: &mut Ctx, v: &Value, td: &TextData) -> Vec { /// Each paragraph's list type, nesting level, start number and base direction from `lineTypes`, /// `lineIndentations` and (from `.fig` files) `listStartOffsets` and `lineDirections`. fn lists(v: &Value, td: &mut TextData) { - let types = list(v, "lineTypes"); - let indents = list(v, "lineIndentations"); - let starts = list(v, "listStartOffsets"); - let dirs = list(v, "lineDirections"); + // One entry per paragraph: read up to the paragraph count (bounded by the text's size), not the + // general list limit, so a long text keeps every paragraph's settings. + let n = td.paragraph_count(); + let column = |k: &str| v.get(k).and_then(Value::as_array).map(|a| a.get(..a.len().min(n)).unwrap_or_default()).unwrap_or_default(); + let (types, indents, starts, dirs) = (column("lineTypes"), column("lineIndentations"), column("listStartOffsets"), column("lineDirections")); td.paragraphs = (0..types.len().max(dirs.len())) .map(|i| ParaList { list: match types.get(i).and_then(Value::as_str) { diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 732e59ec7..bbf498a96 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -265,7 +265,8 @@ impl Item { } } -/// Bidi embedding levels per char, and whether each char's paragraph runs right to left. +/// Bidi embedding levels per char, and whether each char's paragraph runs right to left; +/// `rtl_para` has one more entry, for the position after the last char (an empty last paragraph). pub(crate) struct Bidi { pub(crate) levels: Vec, pub(crate) rtl_para: Vec, @@ -296,6 +297,15 @@ impl Bidi { rtl_para.push(paras.peek().is_some_and(|q| q.level.is_rtl())); } } + // The position after the last char: in the last paragraph, or in the empty one after a + // final newline (its forced direction, else left to right), so an empty last line knows + // its direction too. + let after = if text.is_empty() || text.ends_with('\n') { + forced.get(text.matches('\n').count()).copied().flatten().unwrap_or(false) + } else { + rtl_para.last().copied().unwrap_or(false) + }; + rtl_para.push(after); Some(Bidi { levels, rtl_para }) } } @@ -1271,7 +1281,8 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) // Letter spacing separates glyphs: none after the line's last visible one. let end_spacing = vis_end.checked_sub(1).filter(|i| *i >= *s).and_then(|i| items.get(i)).map_or(0.0, |it| it.spacing); let width: f64 = items.get(*s..vis_end).unwrap_or(&[]).iter().map(|it| it.adv).sum::() - end_spacing; - let first_char = items.get(*s).map(|it| it.char_start).unwrap_or(0); + // An empty last line has no item: the position after the last char is its paragraph's. + let first_char = items.get(*s).map_or(chars.len(), |it| it.char_start); let rtl_line = rtl_para_at(first_char); let line_indent = line_indent_of(*para, para_start_line, first_char); // Hanging punctuation at the line's edges (the visible text's first and last chars). diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index e38963855..0469a0cfc 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -423,17 +423,23 @@ fn forced_direction_sets_a_paragraphs_base_level() { // Latin letters forced right to left: an embedded left-to-right run (level 2) in a // right-to-left paragraph; the next paragraph, unforced, stays left to right. let b = Bidi::of("ab\ncd", &[Some(true), None]).unwrap(); - assert_eq!(b.rtl_para, [true, true, true, false, false]); + assert_eq!(b.rtl_para, [true, true, true, false, false, false], "and the position after the last char"); assert_eq!((b.levels.get(..2), b.levels.get(3..)), (Some(&[2, 2][..]), Some(&[0, 0][..]))); // Hebrew forced left to right: a right-to-left run (level 1) in a left-to-right paragraph. let b = Bidi::of("\u{5D0}\u{5D1}", &[Some(false)]).unwrap(); - assert_eq!(b.rtl_para, [false, false]); + assert_eq!(b.rtl_para, [false, false, false]); assert_eq!(b.levels, [1, 1]); // Forcing what the letters already say changes nothing; a paragraph past the forced list is // detected. let b = Bidi::of("\u{5D0}\n\u{5D1}", &[Some(true)]).unwrap(); - assert_eq!(b.rtl_para, [true, true, true]); + assert_eq!(b.rtl_para, [true, true, true, true]); assert_eq!((b.levels.first(), b.levels.last()), (Some(&1), Some(&1))); + // The empty paragraph after a final newline has its own direction: forced, else left to right. + let b = Bidi::of("ab\n", &[None, Some(true)]).unwrap(); + assert_eq!(b.rtl_para, [false, false, false, true]); + let b = Bidi::of("\u{5D0}\n", &[]).unwrap(); + assert_eq!(b.rtl_para, [true, true, false]); + assert_eq!(Bidi::of("", &[Some(true)]).map(|b| b.rtl_para), Some(vec![true])); } /// The x of the glyph for char `i`. diff --git a/docs/localization-parity.md b/docs/localization-parity.md index ac8f1c3f9..3a887c576 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (Bosnian draft catalog, one/few/other plurals; German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft; Arabic draft catalog, count-neutral plurals and web font gate; Thai draft catalog and Thai UI fonts; a missing translation fails no check: `cargo xtask i18n-report` lists them; Vietnamese draft catalog; string counts re-measured: 2,352 entries; Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (documents: a paragraph's base direction can be forced with Text ▸ Text direction, as Add right-to-left text says (#1830); Bosnian draft catalog, one/few/other plurals; German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft; Arabic draft catalog, count-neutral plurals and web font gate; Thai draft catalog and Thai UI fonts; a missing translation fails no check: `cargo xtask i18n-report` lists them; Vietnamese draft catalog; string counts re-measured: 2,352 entries; Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed) · **Target:** Figma Design (desktop app 126.10.7) FigCraft's interface has a localization layer with an English catalog, a Russian catalog reviewed by a native speaker, and Spanish (with a Latin American overlay), French, German, Brazilian diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5092a08f2..d551c65d1 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -91,7 +91,7 @@ interaction feel not yet compared. | Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | a group past the path operations' caps (250 000 segments, 2 million possible crossings, 4 million segments swept over a fold) draws without its result, with a warning, and Flatten refuses it (#1104) | | Text: point, auto height, fixed; caret, words, lines, IME | done | Backspace or Delete at the start of a list item takes its marker and keeps its level, the paragraph starting where the item's text did ("delete the counter, but keep the same level of indentation", Create bulleted and numbered lists; `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests`); max lines on auto-width text keep it auto width, hugging up to that many lines and growing with its longest line ("Text resizing is set to auto height or auto width", Explore text properties; `text_behaviour_tests::max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines`); Alt+Enter while editing opens the link at the caret, web and mail links only (`text.openLink`; "press ⌥Option Return/Alt Enter to activate a focused link", Accessibility at Figma; `text_link_tests`); the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653) and Up/Down step through it (#352); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; colour emoji draw from their colour tables (#300); IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check | | Text: wrap style (Auto, Balance, Pretty) | done | per text layer (`wrapStyle` on `node.set`, `text.wrapStyle`, the Type settings control under Vertical trim, inspect, CSS `text-wrap: balance` or `pretty`): Auto breaks greedily; Balance narrows each paragraph as far as it goes without another line, so its lines come out close to the same length (what browsers do for `text-wrap: balance`); Pretty keeps lines full and, when the last line is one word, narrows one of the last four lines just enough to give a word to the lines after it (Explore text properties: "Auto: Figma fits as many words as possible on each line before wrapping to the next", "Balance: Figma distributes text as evenly as possible across every line, so each line is close to the same length", "Pretty: Figma keeps each line full, but adjusts the last four lines to avoid an orphan"). Saved line breaks from a file still win. Not yet included: per-paragraph settings, text-style support and the `.fig` import/export mapping (#1827). Test: `text_behaviour_tests::balance_and_pretty_wrap_styles_break_lines_as_documented` | -| Text: direction override (Text ▸ Text direction) | done | per paragraph (`ParaList::direction`: auto, ltr, rtl): Text ▸ Text direction ▸ Auto / Left to right / Right to left and `text.direction {direction, ids?}` set the edited paragraphs while editing, else every paragraph of the selected texts; `node.set textDirection` the layer's (or the chars `start`/`end` name); the Type settings control appears once a right-to-left script is in the text or a direction is forced; inspect reports `textDirection` when the paragraphs agree and `lineDirections` otherwise; Dev Mode's CSS writes `direction: ltr` or `rtl` when every paragraph is forced the same way; `.fig` import and export carry each line's `sourceDirectionality`. Layout gives the paragraph its base level, so a forced right-to-left paragraph of Latin letters starts at the right, mirrors its list gutter and indent and orders its runs right to left, and a forced left-to-right paragraph of Hebrew the other way ("By default, Figma automatically handles text direction based on language detection. However, controls are available to override text direction per paragraph", "Go to Text > Text direction. Select Left to right or Right to left", Add right-to-left text). Not included: a direction in text styles (the article names none). Tests: `text_behaviour_tests::text_direction_is_forced_per_paragraph`, `fig::text_direction_tests` | +| Text: direction override (Text ▸ Text direction) | done | per paragraph (`ParaList::direction`: auto, ltr, rtl): Text ▸ Text direction ▸ Auto / Left to right / Right to left and `text.direction {direction, ids?}` set the edited paragraphs while editing, else every paragraph of the selected texts; `node.set textDirection` the layer's (or the chars `start`/`end` name); the Type settings control appears once a right-to-left script is in the text or a direction is forced; inspect reports `textDirection` when the paragraphs agree and `lineDirections` otherwise; Dev Mode's CSS writes `direction: ltr` or `rtl` when every paragraph is forced the same way (with `text-align: left` beside `rtl`, as Left is absolute on the canvas); `.fig` import and export carry each line's `sourceDirectionality`, read up to the paragraph count. Layout gives the paragraph its base level, so a forced right-to-left paragraph of Latin letters starts at the right, mirrors its list gutter and indent and orders its runs right to left, and a forced left-to-right paragraph of Hebrew the other way ("By default, Figma automatically handles text direction based on language detection. However, controls are available to override text direction per paragraph", "Go to Text > Text direction. Select Left to right or Right to left", Add right-to-left text). Not included: a direction in text styles (the article names none). Tests: `text_behaviour_tests::text_direction_is_forced_per_paragraph`, `fig::text_direction_tests` | | 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 | From a3e96bbb479ea529f5ca5c5c71805e058d1e2a9a Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Thu, 5 Nov 2026 17:06:55 -0500 Subject: [PATCH 13/18] docs: ui-parity status line dated 2026-11-05 with its history row Co-Authored-By: Claude Fable 5.1 --- docs/ui-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 8d4e7d8cb..0f0e92272 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (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`; 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 Windows and Linux, the two keys the help center gives it; 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 keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (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`; 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 Windows and Linux, the two keys the help center gives it; 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 keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7) 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: From 5bdedc39309ba4daaa19d19ccd51eb64eb48fb86 Mon Sep 17 00:00:00 2001 From: Oleksii Sukholita Date: Fri, 6 Nov 2026 19:38:52 +0100 Subject: [PATCH 14/18] M3: a layer binds an effect's field only when its effect has it `variable.bind` on a layer checked only the variable's type against the field's scope, so `effects/0/refraction` on a drop shadow, or a binding on an effect the layer doesn't have (`effects/9/radius`), was kept and did nothing. An effect style already refuses both. The layer now uses the same check: effect `i` must exist and have the field, and the field's type comes from that effect. A binding an older file has on such a field still does nothing, and still unbinds. --- ROADMAP.md | 3 ++- crates/engine/src/assets/style_vars.rs | 21 ++++++++++------ crates/engine/src/binding_lists_tests.rs | 31 ++++++++++++++++++++++++ crates/engine/src/cmd/styles.rs | 3 +++ crates/engine/src/cmd/variables.rs | 20 ++++++++++++++- crates/engine/src/variable_tests.rs | 3 ++- docs/components-parity.md | 5 ++-- 7 files changed, 74 insertions(+), 12 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 60f5962d2..ff4ed5bd2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (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 analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); 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); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); 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); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); 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); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; 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); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); 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; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; 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; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); 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); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (a layer's effect binding takes only a field its effect has; 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 analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); 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); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); 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); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); 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); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; 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); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); 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; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; 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; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); 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); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -358,6 +358,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-11-06 | patch | M3: a layer's effect binding (`variable.bind`, `effects//`) takes only a field its effect has, as an effect style's does: `effects/0/refraction` on a drop shadow, or a binding on an effect the layer doesn't have, is refused instead of being kept and doing nothing (a binding an older file has there still does nothing, and unbinds). Tests: `binding_lists_tests::a_layer_binds_only_fields_its_effects_have`. Numbers unchanged | | 2026-11-02 | patch | M8, #1885: a glass whose shape is too big to keep whole at the zoom (past `MAX_SHAPE_PX` with its core's margin) bends along its edge normals instead of working out a core, so its fields never cover more pixels than a kept shape's, and the core's feature transform holds 16 bytes a pixel. The edge normals are box-blurred twice again and the bend directions once, as documented. Test: `glass::tests::a_shape_too_big_to_keep_bends_along_its_normals`. Numbers unchanged | | 2026-11-02 | minor | M3, #1885: glass bends the backdrop towards its core, the shape shrunk by `depth` and the bevel, as the reference tool's: round a sharp corner the bend turns smoothly instead of meeting along the diagonal, and a part thinner than twice that (a capsule, a letter's stroke) bends across itself towards its own middle (pixels about as deep as any within 2 px). Rectangles, frames, components, instances and sections whose corners aren't smoothed work their glass out in closed form, with nothing to rasterise, transform or keep: a zoom frame draws in 40 ms instead of 54 and a resize frame in 46 instead of 67 (`glass_render_timings`, release, 2×). Other shapes keep their fields, with the bend directions for the last two depths asked for beside them, so dragging the Depth slider works out only those again. Tests: `glass::tests::a_sharp_corner_bends_round_towards_the_core`, `glass::tests::a_thin_part_bends_across_itself`, `glass::tests::neighbouring_strokes_bend_towards_their_own_middles`, `glass::tests::a_rounded_rectangle_in_closed_form_matches_its_distance_transform`, `glass_cut_tests::what_the_shape_depends_on_works_it_out_again_and_nothing_else_does`. Numbers 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 and 0.6 as much on the opposite side, with a faint gradient across the curve, 5% darker facing the light and lighter opposite (it was a glow mixed towards white, as deep as `splay` made it). `splay` turns the bend from along the edge's normal towards the way away from the layer's centre, as the reference tool's does, and no longer widens the light: files with a splay above 0 look different. The direction to the centre is worked out in the shape's own frame, so cut renders match. 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`. Numbers unchanged | diff --git a/crates/engine/src/assets/style_vars.rs b/crates/engine/src/assets/style_vars.rs index 1fea32bf4..a896ae913 100644 --- a/crates/engine/src/assets/style_vars.rs +++ b/crates/engine/src/assets/style_vars.rs @@ -29,13 +29,7 @@ pub fn field_type(kind: &StyleKind, key: &str) -> Option { }; ok.then_some(Color) } - (StyleKind::Effect { effects }, Some((BoundList::Effects, i, field))) => match (&effects.get(i)?.kind, field) { - (EffectKind::DropShadow(_) | EffectKind::InnerShadow(_), "color") => Some(Color), - (EffectKind::DropShadow(_) | EffectKind::InnerShadow(_), "radius" | "spread" | "offsetX" | "offsetY") => Some(Number), - (EffectKind::LayerBlur(_) | EffectKind::BackgroundBlur(_), "radius" | "startRadius") => Some(Number), - (EffectKind::Glass(_), f) if figcraft_doc::Glass::BOUND_FIELDS.contains(&f) => Some(Number), - _ => None, - }, + (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), @@ -46,6 +40,19 @@ pub fn field_type(kind: &StyleKind, key: &str) -> Option { } } +/// The type of variable an effect of `kind` takes on `field` (the last part of an +/// `effects//` key), on a layer or in an effect style; None when it has no such field. +pub fn effect_field_type(kind: &EffectKind, field: &str) -> Option { + use VariableType::{Color, Number}; + match (kind, field) { + (EffectKind::DropShadow(_) | EffectKind::InnerShadow(_), "color") => Some(Color), + (EffectKind::DropShadow(_) | EffectKind::InnerShadow(_), "radius" | "spread" | "offsetX" | "offsetY") => Some(Number), + (EffectKind::LayerBlur(_) | EffectKind::BackgroundBlur(_), "radius" | "startRadius") => Some(Number), + (EffectKind::Glass(_), f) if figcraft_doc::Glass::BOUND_FIELDS.contains(&f) => Some(Number), + _ => None, + } +} + /// The variable scopes a picker offers for `key` of a style of `kind`: a variable scoped to any one /// of them fits. A paint style's colors take fill and stroke variables, an effect style's fields /// take effect variables, and a text style's fields take the variables of their font property. diff --git a/crates/engine/src/binding_lists_tests.rs b/crates/engine/src/binding_lists_tests.rs index cddd6bbcb..8bceafff0 100644 --- a/crates/engine/src/binding_lists_tests.rs +++ b/crates/engine/src/binding_lists_tests.rs @@ -129,6 +129,37 @@ fn glass_fields_take_number_variables() { assert!(s.execute("style.bind", &json!({"style": fx, "field": "effects/0/spread", "variable": "large"})).is_err(), "glass has no spread"); } +#[test] +fn a_layer_binds_only_fields_its_effects_have() { + let (mut s, r) = setup(); + ex(&mut s, "effect.add", json!({"ids": [r], "type": "dropShadow"})); + ex(&mut s, "effect.add", json!({"ids": [r], "type": "glass"})); + // As an effect style does: a drop shadow has no refraction, glass no spread, and there is no + // effect 5. Nothing is bound. + for (field, var, says) in [ + ("effects/0/refraction", "large", "no field"), + ("effects/1/spread", "large", "no field"), + ("effects/5/radius", "large", "no field"), + ("effects/1/lightAngle", "brand", "doesn't take a color variable"), + ("effects/0/color", "large", "doesn't take a number variable"), + ] { + let e = s.execute("variable.bind", &json!({"ids": [r], "field": field, "variable": var})).unwrap_err().to_string(); + assert!(e.contains(says), "{field} ← {var}: {e}"); + } + assert!(s.doc().unwrap().node(r).unwrap().bound.is_empty()); + // The fields each effect has still bind. + ex(&mut s, "variable.bind", json!({"ids": [r], "field": "effects/0/spread", "variable": "small"})); + ex(&mut s, "variable.bind", json!({"ids": [r], "field": "effects/1/refraction", "variable": "large"})); + assert_eq!(s.doc().unwrap().node(r).unwrap().bound.len(), 2); + // A binding an older file already has on a field its effect lacks does nothing, and unbinds. + let large = s.doc().unwrap().variables.get("large").unwrap().id.clone(); + s.doc_mut().unwrap().get_mut(r).unwrap().bound.insert("effects/0/refraction".into(), large); + ex(&mut s, "variable.set", json!({"variable": "large", "value": 40})); + assert!(matches!(&s.doc().unwrap().node(r).unwrap().effects[0].kind, EffectKind::DropShadow(_))); + ex(&mut s, "variable.bind", json!({"ids": [r], "field": "effects/0/refraction"})); + assert!(!s.doc().unwrap().node(r).unwrap().bound.contains_key("effects/0/refraction")); +} + fn ex_value(s: &mut Session, cmd: &str, p: serde_json::Value) -> serde_json::Value { s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) } diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index 0bd43e388..013431616 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -788,6 +788,9 @@ pub(crate) fn bind_variable(cmd: &str, doc: &mut Document, ids: &[NodeId], field for i in ids { match field.strip_prefix(crate::components::PROP_BINDING) { Some(name) => super::component::check_prop_binding(doc, *i, name, v.kind).map_err(|e| bad(cmd, e))?, + None if !super::variables::has_field(doc, *i, field) => { + return Err(bad(cmd, format!("the layer has no field `{field}` to bind"))); + } None if !super::variables::field_takes(doc, *i, field, v.kind) => { return Err(bad(cmd, format!("`{field}` doesn't take a {} variable", v.kind.id()))); } diff --git a/crates/engine/src/cmd/variables.rs b/crates/engine/src/cmd/variables.rs index 59ad46ced..33fb1c973 100644 --- a/crates/engine/src/cmd/variables.rs +++ b/crates/engine/src/cmd/variables.rs @@ -654,10 +654,28 @@ pub fn field_scope(doc: &Document, node: NodeId, field: &str) -> Option<&'static }) } +/// The type of variable effect `i` of `node` takes on `field`, as an effect style's does: None when +/// the layer has no effect `i` or that effect has no such field. +fn effect_field(doc: &Document, node: NodeId, i: usize, field: &str) -> Option { + crate::style_vars::effect_field_type(&doc.node(node)?.effects.get(i)?.kind, field) +} + +/// Whether `node` has the field a binding key names. Only effect fields are checked: a layer has +/// `effects//` when its effect `i` has that field (a drop shadow has no `refraction`). +pub fn has_field(doc: &Document, node: NodeId, field: &str) -> bool { + match super::lists::list_binding(field) { + Some((BoundList::Effects, i, f)) => effect_field(doc, node, i, f).is_some(), + _ => true, + } +} + /// Whether property `field` of `node` takes a variable of type `kind`: the type its scope belongs /// to (number variables also fill text content), booleans for visibility, numbers for layout grid -/// fields; fields without a known type take any. +/// fields, an effect's field the type its effect takes there; fields without a known type take any. pub fn field_takes(doc: &Document, node: NodeId, field: &str, kind: VariableType) -> bool { + if let Some((BoundList::Effects, i, f)) = super::lists::list_binding(field) { + return effect_field(doc, node, i, f) == Some(kind); + } match (field, field_scope(doc, node, field)) { ("visible", _) => kind == VariableType::Boolean, (_, Some(scope)) => scope_known(kind, scope), diff --git a/crates/engine/src/variable_tests.rs b/crates/engine/src/variable_tests.rs index dcdd04e32..09e16584c 100644 --- a/crates/engine/src/variable_tests.rs +++ b/crates/engine/src/variable_tests.rs @@ -172,7 +172,8 @@ fn effects_follow_their_variables() { bind(&mut s, "effects/0/offsetY", "y"); bind(&mut s, "effects/0/color", "shadow"); bind(&mut s, "effects/1/radius", "blur"); - bind(&mut s, "effects/9/radius", "blur"); + // There is no effect 9 to bind (see `binding_lists_tests::a_layer_binds_only_fields_its_effects_have`). + assert!(s.execute("variable.bind", &json!({"ids": [r], "field": "effects/9/radius", "variable": "blur"})).is_err()); let fx = |s: &Session| s.doc().unwrap().node(r).unwrap().effects.clone(); match &fx(&s)[0].kind { figcraft_doc::EffectKind::DropShadow(sh) => { diff --git a/docs/components-parity.md b/docs/components-parity.md index 94a579758..107c23a2d 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -1,6 +1,6 @@ # Components, variants, styles and variables parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (glass effects take variables; Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (a layer's effect binding takes only a field its effect has; 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 @@ -67,7 +67,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | 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 | | Code syntax per platform (Web, iOS, Android) | done | generated CSS uses the Web name, SwiftUI the iOS one and Compose the Android one (a variable without one keeps its value there); in the variables JSON; ArkUI and Flutter have no platform of their own and write values | -| Bindings: fills, strokes, effects, opacity, padding, gaps, sizes, radius, font family/style, font size/weight, line height, letter spacing, paragraph spacing/indent, text, visibility, layout grids, instance properties | done | type-checked per field; glass fields too (light angle, intensity, refraction, depth, dispersion, frost and splay, percentages as the Design panel shows them); number variables fill text content; the Design panel's font family and font style menus start with Apply variable (string variables), and a bound one shows its variable in place of the menu; a colour variable bound to a solid paint brings its whole colour, alpha included, once (the paint's opacity restarts at 100% on binding, an opacity set while bound multiplies on top; unbinding moves the alpha back into the opacity) | +| Bindings: fills, strokes, effects, opacity, padding, gaps, sizes, radius, font family/style, font size/weight, line height, letter spacing, paragraph spacing/indent, text, visibility, layout grids, instance properties | done | type-checked per field, an effect's only where its effect has the field (as in effect styles); glass fields too (light angle, intensity, refraction, depth, dispersion, frost and splay, percentages as the Design panel shows them); number variables fill text content; the Design panel's font family and font style menus start with Apply variable (string variables), and a bound one shows its variable in place of the menu; a colour variable bound to a solid paint brings its whole colour, alpha included, once (the paint's opacity restarts at 100% on binding, an opacity set while bound multiplies on top; unbinding moves the alpha back into the opacity) | | Explicit modes per frame, library collections' modes | done | stored variable and collection references resolve by exact id in export, library copies and swaps, imported aliases, alias deletion and cycle checks, Dev Inspect, the library mode picker and prototype playback; a collection or variable named like another's id no longer stands in for it there | | Gradient stops and variant properties bound to variables | done | a stop's binding follows it when earlier stops are removed | | Extended collections | done | this file's collections (Extend collection on a collection's row; following its edits at once, chains included) and library collections, including a library's own extended collections (chains through libraries); overrides per mode with reset; follow library updates, variables new to the library collection being an update of their own; modes inherited. Checked with synthetic libraries only ([gaps](gaps.md) #14) | @@ -78,6 +78,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Date | Change | Summary | |---|---|---| +| 2026-11-06 | patch | M3: a layer's effect binding (`variable.bind`, `effects//`) takes only a field its effect has, as an effect style's does: `effects/0/refraction` on a drop shadow, or a binding on an effect the layer doesn't have, is refused instead of being kept and doing nothing (a binding an older file has there still does nothing, and unbinds). Tests: `binding_lists_tests::a_layer_binds_only_fields_its_effects_have`. Grade unchanged. | | 2026-11-02 | minor | M3, #1885: glass effects take variables, as the reference tool's do: its light angle, intensity, refraction, depth, dispersion, frost and splay bind to number variables on a layer (`variable.bind`, `effects//`, `radius` being the frost) and in an effect style (`style.bind`), and the Design panel's glass fields take a variable like the shadow and blur fields. A variable reads as the field shows it: intensity, refraction, dispersion and splay in percent. Tests: `binding_lists_tests::glass_fields_take_number_variables`, `effects_tests::glass_fields_take_bound_numbers_as_the_panel_shows_them`. Grade unchanged. | | 2026-11-01 | patch | M3: Delete on a layer inside an instance hides it (an override) instead of being refused ("You can't delete a layer or object from an instance. If you try, Figma will only toggle the layer's visibility instead of removing it", Guide to auto layout); `edit.delete` reports those ids under `hidden`, the structure and the main component stay as they were, one undo step; deleting the instance itself still removes it. Found by a documentation audit of the auto layout page. Test: `components_behaviour_tests::deleting_a_layer_inside_an_instance_hides_it_instead` (the refusal row for it is gone from `what_instances_cannot_override_is_refused`). Numbers unchanged | | 2026-10-31 | minor | M3, gap 10: library analytics cover styles and variables. A file reports how many of its layers use each of a library's styles and variables, and Show usage (`library.usage`) lists them beside the components, by a Type choice, with their layers and files. Layers inside instances count for their component. Insertions, detachments and modes aren't counted for them yet. Numbers unchanged. Tests: `library_assets_tests`. | From 104aa3deb147614aec18437406e330a38c64bd00 Mon Sep 17 00:00:00 2001 From: Oleksii Sukholita Date: Fri, 6 Nov 2026 19:39:12 +0100 Subject: [PATCH 15/18] =?UTF-8?q?M3:=20a=20glass's=20bound=20light=20angle?= =?UTF-8?q?=20turns=20round=20past=20=C2=B1180=20instead=20of=20stopping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Glass::set_bound` clamped a bound light angle to ±180, so a variable of 270 drew the light from 180, a quarter turn off, while a typed 270 draws from -90. A bound angle now turns round into -180–180 (270 is -90, -0 is 0), the same direction the angle draws in. --- ROADMAP.md | 3 ++- crates/doc/src/effects_tests.rs | 22 ++++++++++++++++++++-- crates/doc/src/props.rs | 14 +++++++++++--- docs/components-parity.md | 5 +++-- 4 files changed, 36 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ff4ed5bd2..4ce120b26 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (a layer's effect binding takes only a field its effect has; 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 analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); 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); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); 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); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); 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); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; 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); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); 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; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; 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; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); 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); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (a layer's effect binding takes only a field its effect has; a bound glass light angle past ±180 turns round; glass 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 analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); 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); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); 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); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); 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); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; 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); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); 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; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; 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; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); 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); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -358,6 +358,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-11-06 | patch | M3: a glass's light angle bound to a number past ±180 turns round to the same direction (270 is -90), as the angle drawn from it does, instead of stopping at 180. Tests: `effects_tests::a_bound_light_angle_turns_round_instead_of_stopping`. Numbers unchanged | | 2026-11-06 | patch | M3: a layer's effect binding (`variable.bind`, `effects//`) takes only a field its effect has, as an effect style's does: `effects/0/refraction` on a drop shadow, or a binding on an effect the layer doesn't have, is refused instead of being kept and doing nothing (a binding an older file has there still does nothing, and unbinds). Tests: `binding_lists_tests::a_layer_binds_only_fields_its_effects_have`. Numbers unchanged | | 2026-11-02 | patch | M8, #1885: a glass whose shape is too big to keep whole at the zoom (past `MAX_SHAPE_PX` with its core's margin) bends along its edge normals instead of working out a core, so its fields never cover more pixels than a kept shape's, and the core's feature transform holds 16 bytes a pixel. The edge normals are box-blurred twice again and the bend directions once, as documented. Test: `glass::tests::a_shape_too_big_to_keep_bends_along_its_normals`. Numbers unchanged | | 2026-11-02 | minor | M3, #1885: glass bends the backdrop towards its core, the shape shrunk by `depth` and the bevel, as the reference tool's: round a sharp corner the bend turns smoothly instead of meeting along the diagonal, and a part thinner than twice that (a capsule, a letter's stroke) bends across itself towards its own middle (pixels about as deep as any within 2 px). Rectangles, frames, components, instances and sections whose corners aren't smoothed work their glass out in closed form, with nothing to rasterise, transform or keep: a zoom frame draws in 40 ms instead of 54 and a resize frame in 46 instead of 67 (`glass_render_timings`, release, 2×). Other shapes keep their fields, with the bend directions for the last two depths asked for beside them, so dragging the Depth slider works out only those again. Tests: `glass::tests::a_sharp_corner_bends_round_towards_the_core`, `glass::tests::a_thin_part_bends_across_itself`, `glass::tests::neighbouring_strokes_bend_towards_their_own_middles`, `glass::tests::a_rounded_rectangle_in_closed_form_matches_its_distance_transform`, `glass_cut_tests::what_the_shape_depends_on_works_it_out_again_and_nothing_else_does`. Numbers unchanged | diff --git a/crates/doc/src/effects_tests.rs b/crates/doc/src/effects_tests.rs index f1676f2dd..89462f45e 100644 --- a/crates/doc/src/effects_tests.rs +++ b/crates/doc/src/effects_tests.rs @@ -75,10 +75,10 @@ fn glass_fields_take_bound_numbers_as_the_panel_shows_them() { } assert_eq!((g.light_angle, g.light_intensity, g.refraction, g.depth, g.dispersion, g.frost, g.splay), (30.0, 0.25, 0.4, 12.0, 0.1, 8.0, 0.5)); // Out of range: clamped to the field's range. - for (field, x) in [("lightAngle", 270.0), ("lightIntensity", 250.0), ("depth", 0.0), ("radius", 150.0), ("splay", -5.0)] { + for (field, x) in [("lightIntensity", 250.0), ("depth", 0.0), ("radius", 150.0), ("splay", -5.0)] { assert!(g.set_bound(field, x), "{field}"); } - assert_eq!((g.light_angle, g.light_intensity, g.depth, g.frost, g.splay), (180.0, 1.0, 1.0, Glass::MAX_FROST, 0.0)); + assert_eq!((g.light_intensity, g.depth, g.frost, g.splay), (1.0, 1.0, Glass::MAX_FROST, 0.0)); // A field glass doesn't have, or a value that isn't a number, changes nothing. let before = g.clone(); assert!(!g.set_bound("spread", 3.0)); @@ -90,6 +90,24 @@ fn glass_fields_take_bound_numbers_as_the_panel_shows_them() { } } +#[test] +fn a_bound_light_angle_turns_round_instead_of_stopping() { + // An angle past ±180 is the same direction a turn away (270 is -90), not the end of the range. + for (x, want) in [(270.0, -90.0), (-270.0, 90.0), (540.0, 180.0), (-180.0, 180.0), (180.0, 180.0), (720.5, 0.5), (-360.0, 0.0), (-45.0, -45.0)] { + let mut g = Glass::default(); + assert!(g.set_bound("lightAngle", x), "{x}"); + assert_eq!(g.light_angle, want, "{x}"); + } + // A whole turn back is 0, not -0, so a saved file says 0. + let mut g = Glass::default(); + assert!(g.set_bound("lightAngle", -360.0)); + assert!(g.light_angle == 0.0 && g.light_angle.is_sign_positive()); + // A huge angle still lands in range. + let mut g = Glass::default(); + assert!(g.set_bound("lightAngle", 1e300)); + assert!((-180.0..=180.0).contains(&g.light_angle) && g.light_angle != -180.0); +} + #[test] fn effect_colours() { let mut e = Effect::noise(); diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index 11c3c16a4..88447d4a3 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -673,6 +673,13 @@ impl Default for Glass { } } +/// `deg` (finite) as the same direction within -180 (exclusive) to 180 degrees. +fn wrap_degrees(deg: f64) -> f64 { + // `+ 0.0`: -0 reads as 0, so a saved file says 0. + let a = deg.rem_euclid(360.0) + 0.0; + if a > 180.0 { a - 360.0 } else { a } +} + impl Glass { /// The most frost a glass takes, as the reference tool's Frost field (0–100). pub const MAX_FROST: f64 = 100.0; @@ -682,15 +689,16 @@ impl Glass { /// Sets `field` (one of [`Self::BOUND_FIELDS`]) from a bound number variable, read as the /// Design panel's field shows it: intensity, refraction, dispersion and splay in percent, the - /// frost as `radius`, each clamped to the field's range. False for a field glass doesn't have - /// or a value that isn't finite (nothing changes). + /// frost as `radius`, each clamped to the field's range, and the light angle turned into + /// -180–180 (270 is -90, the same way). False for a field glass doesn't have or a value that + /// isn't finite (nothing changes). pub fn set_bound(&mut self, field: &str, x: f64) -> bool { if !x.is_finite() { return false; } let pct = (x / 100.0).clamp(0.0, 1.0); match field { - "lightAngle" => self.light_angle = x.clamp(-180.0, 180.0), + "lightAngle" => self.light_angle = wrap_degrees(x), "lightIntensity" => self.light_intensity = pct, "refraction" => self.refraction = pct, "depth" => self.depth = x.clamp(1.0, 1000.0), diff --git a/docs/components-parity.md b/docs/components-parity.md index 107c23a2d..8120f7787 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -1,6 +1,6 @@ # Components, variants, styles and variables parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (a layer's effect binding takes only a field its effect has; glass effects take variables; Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (a layer's effect binding takes only a field its effect has; a bound glass light angle past ±180 turns round; glass effects take variables; Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) 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 @@ -67,7 +67,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | 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 | | Code syntax per platform (Web, iOS, Android) | done | generated CSS uses the Web name, SwiftUI the iOS one and Compose the Android one (a variable without one keeps its value there); in the variables JSON; ArkUI and Flutter have no platform of their own and write values | -| Bindings: fills, strokes, effects, opacity, padding, gaps, sizes, radius, font family/style, font size/weight, line height, letter spacing, paragraph spacing/indent, text, visibility, layout grids, instance properties | done | type-checked per field, an effect's only where its effect has the field (as in effect styles); glass fields too (light angle, intensity, refraction, depth, dispersion, frost and splay, percentages as the Design panel shows them); number variables fill text content; the Design panel's font family and font style menus start with Apply variable (string variables), and a bound one shows its variable in place of the menu; a colour variable bound to a solid paint brings its whole colour, alpha included, once (the paint's opacity restarts at 100% on binding, an opacity set while bound multiplies on top; unbinding moves the alpha back into the opacity) | +| Bindings: fills, strokes, effects, opacity, padding, gaps, sizes, radius, font family/style, font size/weight, line height, letter spacing, paragraph spacing/indent, text, visibility, layout grids, instance properties | done | type-checked per field, an effect's only where its effect has the field (as in effect styles); glass fields too (light angle, intensity, refraction, depth, dispersion, frost and splay, percentages as the Design panel shows them, a light angle past ±180 turned round); number variables fill text content; the Design panel's font family and font style menus start with Apply variable (string variables), and a bound one shows its variable in place of the menu; a colour variable bound to a solid paint brings its whole colour, alpha included, once (the paint's opacity restarts at 100% on binding, an opacity set while bound multiplies on top; unbinding moves the alpha back into the opacity) | | Explicit modes per frame, library collections' modes | done | stored variable and collection references resolve by exact id in export, library copies and swaps, imported aliases, alias deletion and cycle checks, Dev Inspect, the library mode picker and prototype playback; a collection or variable named like another's id no longer stands in for it there | | Gradient stops and variant properties bound to variables | done | a stop's binding follows it when earlier stops are removed | | Extended collections | done | this file's collections (Extend collection on a collection's row; following its edits at once, chains included) and library collections, including a library's own extended collections (chains through libraries); overrides per mode with reset; follow library updates, variables new to the library collection being an update of their own; modes inherited. Checked with synthetic libraries only ([gaps](gaps.md) #14) | @@ -78,6 +78,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Date | Change | Summary | |---|---|---| +| 2026-11-06 | patch | M3: a glass's light angle bound to a number past ±180 turns round to the same direction (270 is -90), as the angle drawn from it does, instead of stopping at 180. Tests: `effects_tests::a_bound_light_angle_turns_round_instead_of_stopping`. Grade unchanged. | | 2026-11-06 | patch | M3: a layer's effect binding (`variable.bind`, `effects//`) takes only a field its effect has, as an effect style's does: `effects/0/refraction` on a drop shadow, or a binding on an effect the layer doesn't have, is refused instead of being kept and doing nothing (a binding an older file has there still does nothing, and unbinds). Tests: `binding_lists_tests::a_layer_binds_only_fields_its_effects_have`. Grade unchanged. | | 2026-11-02 | minor | M3, #1885: glass effects take variables, as the reference tool's do: its light angle, intensity, refraction, depth, dispersion, frost and splay bind to number variables on a layer (`variable.bind`, `effects//`, `radius` being the frost) and in an effect style (`style.bind`), and the Design panel's glass fields take a variable like the shadow and blur fields. A variable reads as the field shows it: intensity, refraction, dispersion and splay in percent. Tests: `binding_lists_tests::glass_fields_take_number_variables`, `effects_tests::glass_fields_take_bound_numbers_as_the_panel_shows_them`. Grade unchanged. | | 2026-11-01 | patch | M3: Delete on a layer inside an instance hides it (an override) instead of being refused ("You can't delete a layer or object from an instance. If you try, Figma will only toggle the layer's visibility instead of removing it", Guide to auto layout); `edit.delete` reports those ids under `hidden`, the structure and the main component stay as they were, one undo step; deleting the instance itself still removes it. Found by a documentation audit of the auto layout page. Test: `components_behaviour_tests::deleting_a_layer_inside_an_instance_hides_it_instead` (the refusal row for it is gone from `what_instances_cannot_override_is_refused`). Numbers unchanged | From d23cebb12f47e45e625369bb2c0cdfeb2fd6a2a3 Mon Sep 17 00:00:00 2001 From: Oleksii Sukholita Date: Fri, 6 Nov 2026 20:18:10 +0100 Subject: [PATCH 16/18] M3: an effects key naming no effect's field binds nothing (#2055) Review follow-up: `effects/x/radius`, `effects/0` and `effects` name no effect's field, but `list_binding` doesn't parse them, so the layer's check took them for some other field and kept an inert binding. A key under `effects` that names no effect's field is now refused as well. --- crates/engine/src/binding_lists_tests.rs | 7 +++++-- crates/engine/src/cmd/variables.rs | 5 +++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/binding_lists_tests.rs b/crates/engine/src/binding_lists_tests.rs index 8bceafff0..5886a5484 100644 --- a/crates/engine/src/binding_lists_tests.rs +++ b/crates/engine/src/binding_lists_tests.rs @@ -134,12 +134,15 @@ fn a_layer_binds_only_fields_its_effects_have() { let (mut s, r) = setup(); ex(&mut s, "effect.add", json!({"ids": [r], "type": "dropShadow"})); ex(&mut s, "effect.add", json!({"ids": [r], "type": "glass"})); - // As an effect style does: a drop shadow has no refraction, glass no spread, and there is no - // effect 5. Nothing is bound. + // As an effect style does: a drop shadow has no refraction, glass no spread, there is no + // effect 5, and a key that names no effect's field names nothing. Nothing is bound. for (field, var, says) in [ ("effects/0/refraction", "large", "no field"), ("effects/1/spread", "large", "no field"), ("effects/5/radius", "large", "no field"), + ("effects/x/radius", "large", "no field"), + ("effects/0", "large", "no field"), + ("effects", "large", "no field"), ("effects/1/lightAngle", "brand", "doesn't take a color variable"), ("effects/0/color", "large", "doesn't take a number variable"), ] { diff --git a/crates/engine/src/cmd/variables.rs b/crates/engine/src/cmd/variables.rs index 33fb1c973..b6f4d291e 100644 --- a/crates/engine/src/cmd/variables.rs +++ b/crates/engine/src/cmd/variables.rs @@ -661,11 +661,12 @@ fn effect_field(doc: &Document, node: NodeId, i: usize, field: &str) -> Option/` when its effect `i` has that field (a drop shadow has no `refraction`). +/// `effects//` when its effect `i` has that field (a drop shadow has no `refraction`), +/// and an `effects…` key naming no effect's field (`effects/x/radius`, `effects/0`) has none. pub fn has_field(doc: &Document, node: NodeId, field: &str) -> bool { match super::lists::list_binding(field) { Some((BoundList::Effects, i, f)) => effect_field(doc, node, i, f).is_some(), - _ => true, + _ => field.split('/').next() != Some(BoundList::Effects.prefix()), } } From cf1fed3332c2a55bb6fea6d0c68c6836b43b0ce2 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 7 Nov 2026 07:58:49 +0700 Subject: [PATCH 17/18] Spanish labels for the text direction commands --- crates/ui-egui/locales/es.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index b9e004dd0..96183d523 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -2318,6 +2318,10 @@ status = "draft — needs native review" "text.caseUpper" = "Mayúsculas" "text.create" = "Texto" "text.decoration" = "Decoración" +"text.direction" = "Dirección del texto" +"text.directionAuto" = "Dirección del texto: automática" +"text.directionLtr" = "Dirección del texto: de izquierda a derecha" +"text.directionRtl" = "Dirección del texto: de derecha a izquierda" "text.edit.begin" = "Editar texto" "text.edit.caret" = "Colocar el cursor de texto" "text.edit.end" = "Dejar de editar el texto" From 7eac937accbe61d1e29243f06588db268aaabfa4 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 7 Nov 2026 08:24:02 +0700 Subject: [PATCH 18/18] Style-heavy files: a repeated name keeps its first place, and one more lookup uses the id set --- crates/format/src/penpot.rs | 2 +- crates/format/src/rest.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/format/src/penpot.rs b/crates/format/src/penpot.rs index 64174e084..7e1a563e9 100644 --- a/crates/format/src/penpot.rs +++ b/crates/format/src/penpot.rs @@ -2157,7 +2157,7 @@ fn import_themed_tokens( } // Each set's place in the order once: a position search per comparison was quadratic in // the order's length for every one of the sort's comparisons. - let place: BTreeMap<&str, usize> = order.iter().enumerate().map(|(i, o)| (*o, i)).collect(); + let place: BTreeMap<&str, usize> = order.iter().enumerate().rev().map(|(i, o)| (*o, i)).collect(); set_names.sort_by_key(|name| place.get(name).copied().unwrap_or(usize::MAX)); let set_names: Vec<&str> = set_names.into_iter().take(512).collect(); let mut tokens: BTreeMap<&str, BTreeMap> = BTreeMap::new(); diff --git a/crates/format/src/rest.rs b/crates/format/src/rest.rs index 540be361a..823082af1 100644 --- a/crates/format/src/rest.rs +++ b/crates/format/src/rest.rs @@ -922,7 +922,7 @@ impl<'a> Ctx<'a> { fn style_definitions(&mut self, v: &'a Value) { for d in list(v, "styleDefinitions").iter().take(MAX_STYLES) { let (Some(id), Some(node)) = (text(d, "id").filter(|i| !i.is_empty()).map(clip_str), d.get("node")) else { continue }; - if self.doc.styles.iter().any(|s| s.id == id) { + if self.style_ids.contains(id) { continue; } let kind = match text(d, "styleType") {