From 475a7da502470fdcbd1b6b6d702e5d2e48affbd2 Mon Sep 17 00:00:00 2001 From: Ahmed Mahmoud Date: Thu, 29 Oct 2026 08:42:52 +0300 Subject: [PATCH 01/63] M1: guides snap to layer edges and centres when dragged Dragging a guide, or moving one, now snaps to nearby layer edges and centres on the guide's own axis: within 5 screen pixels, gated by Snap to Objects and suspended with Cmd/Ctrl. Page guides see the page's top-level layers; frame guides see the frame's box, its layout grids and every visible layer inside it, in the frame's own space. Snapping sits in the interaction path (canvas/guides.rs, not guide.add/guide.move) so the commands stay deterministic for the control channel and MCP. Tests: guide_snap_tests.rs in the engine and ui-egui crates; docs/ui-parity.md updated. Signed-off-by: Ahmed Mahmoud --- crates/engine/src/canvas/snap.rs | 61 +++++++++++++++ crates/engine/src/guide_snap_tests.rs | 100 +++++++++++++++++++++++++ crates/engine/src/lib.rs | 2 + crates/ui-egui/src/canvas/guides.rs | 35 ++++++--- crates/ui-egui/src/guide_snap_tests.rs | 97 ++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 2 + docs/ui-parity.md | 5 +- 7 files changed, 288 insertions(+), 14 deletions(-) create mode 100644 crates/engine/src/guide_snap_tests.rs create mode 100644 crates/ui-egui/src/guide_snap_tests.rs diff --git a/crates/engine/src/canvas/snap.rs b/crates/engine/src/canvas/snap.rs index b68f1fe9..3cd832dc 100644 --- a/crates/engine/src/canvas/snap.rs +++ b/crates/engine/src/canvas/snap.rs @@ -9,6 +9,7 @@ use figcraft_geom::{Affine, Point, Rect, Vec2, transform_rect_bbox}; use serde::Serialize; use crate::guides; +use crate::tools::SNAP_PX; /// A guide line to draw: from (x0, y0) to (x1, y1) in page coordinates, with an optional label /// (a distance) at its middle; `dashed` marks a measurement's extension line. @@ -127,6 +128,48 @@ impl Targets { t } + /// Snap lines for a guide on `owner` (a page or a top-level frame): a page's top-level layers, + /// or a frame's own box, layout grids and descendants, in the owner's own space so a rotated + /// frame's guide snaps along its axes. + pub fn gather_for_guide(doc: &Document, page: NodeId, owner: NodeId) -> Targets { + let space = if owner == page { Affine::IDENTITY } else { doc.world_transform(owner) }; + let to = space.inverse(); + let bbox = |id: NodeId| doc.node(id).map(|n| transform_rect_bbox(to * doc.world_transform(id), n.local_rect())).filter(|b| b.is_finite()); + let mut t = Targets { space, ..Default::default() }; + if owner == page { + if let Some(p) = doc.node(page) { + for c in &p.children { + if doc.node(*c).is_some_and(|n| n.visible) + && let Some(b) = bbox(*c) + { + t.add_box(b); + } + } + } + return t; + } + // The frame's own box and its layout grids. + if let Some(b) = bbox(owner) { + t.add_box(b); + let (gx, gy) = figcraft_layout::grid_snap_lines_in(doc, owner, to * doc.world_transform(owner)); + for x in gx { + t.xs.push((x, b.y0, b.y1)); + } + for y in gy { + t.ys.push((y, b.x0, b.x1)); + } + } + // Everything in the frame. Nested layers count, so a guide over a group reaches its parts. + for id in doc.subtree(owner).into_iter().skip(1) { + if doc.node(id).is_some_and(|n| n.visible) + && let Some(b) = bbox(id) + { + t.add_box(b); + } + } + t + } + /// A box to snap to (in snap space): its edges and centre lines, and equal spacing with it. pub fn add_box(&mut self, r: Rect) { for x in [r.x0, r.center().x, r.x1] { @@ -317,6 +360,16 @@ impl Targets { (dx, dy, self.guides_spans(&xs, &ys)) } + /// The correction along one axis (x when `vertical`, else y) to the nearest target line within + /// `threshold`, in snap space. A guide moves on its own axis only, so the other is left alone. + pub fn snap_axis(&self, vertical: bool, offset: f64, threshold: f64) -> Option { + if !offset.is_finite() || !threshold.is_finite() || threshold < 0.0 { + return None; + } + let lines = if vertical { &self.xs } else { &self.ys }; + lines.iter().map(|(v, _, _)| v - offset).filter(|d| d.is_finite() && d.abs() <= threshold).min_by(|a, b| a.abs().total_cmp(&b.abs())) + } + /// Guide lines for edges of `r` that coincide with targets. pub fn guides_for(&self, r: Rect) -> Vec { let (xs, ys) = rect_spans(r); @@ -345,6 +398,14 @@ impl Targets { } } +/// Snap a guide on `owner` (a page or a top-level frame) to the nearest layer edge or centre along +/// its own axis, within `SNAP_PX` screen pixels at `zoom`. Returns the new offset in `owner`'s +/// coordinates, or `None` when nothing is in reach. +pub fn snap_guide(doc: &Document, page: NodeId, owner: NodeId, vertical: bool, offset: f64, zoom: f64) -> Option { + let threshold = SNAP_PX / zoom.max(1e-6); + Targets::gather_for_guide(doc, page, owner).snap_axis(vertical, offset, threshold).map(|d| offset + d).filter(|v| v.is_finite()) +} + /// The parent of the moving layers (the first one's), or the page. fn parent_of(doc: &Document, page: NodeId, moving: &[NodeId]) -> NodeId { moving.first().and_then(|m| doc.node(*m)).and_then(|n| n.parent).unwrap_or(page) diff --git a/crates/engine/src/guide_snap_tests.rs b/crates/engine/src/guide_snap_tests.rs new file mode 100644 index 00000000..3402ef55 --- /dev/null +++ b/crates/engine/src/guide_snap_tests.rs @@ -0,0 +1,100 @@ +//! Guides snap to layer edges and centres when dragged out of a ruler or moved, on the guide's own +//! axis only, within five screen pixels at any zoom (page guides see the page's top-level layers; +//! frame guides see the frame's box, layout grids and every layer in it). + +use figcraft_doc::NodeId; +use figcraft_geom::Affine; +use serde_json::json; + +use crate::Session; +use crate::params::id_of; +use crate::snap::{Targets, snap_guide}; + +fn id(v: serde_json::Value) -> NodeId { + id_of(&v["id"]).unwrap() +} + +fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-6 +} + +fn page_snap(s: &Session, vertical: bool, offset: f64, zoom: f64) -> Option { + let (doc, page) = (s.doc().unwrap(), s.page().unwrap()); + snap_guide(doc, page, page, vertical, offset, zoom) +} + +#[test] +fn a_page_guide_snaps_to_a_top_level_layers_edges_and_centre() { + let mut s = Session::new(); + s.execute("node.create", &json!({"type": "rectangle", "x": 100, "y": 300, "width": 50, "height": 40})).unwrap(); + // Edges at 100 and 150, centre at 125; within five pixels at zoom 1. + assert_eq!(page_snap(&s, true, 102.0, 1.0), Some(100.0)); + assert_eq!(page_snap(&s, true, 124.0, 1.0), Some(125.0)); + assert_eq!(page_snap(&s, true, 148.0, 1.0), Some(150.0)); + // Out of reach. + assert_eq!(page_snap(&s, true, 106.0, 1.0), None); + // The guide's own axis only: x 302 is far from the x edges, y 302 is a pixel above 300. + assert_eq!(page_snap(&s, true, 302.0, 1.0), None); + assert_eq!(page_snap(&s, false, 302.0, 1.0), Some(300.0)); +} + +#[test] +fn the_reach_is_five_screen_pixels_at_any_zoom() { + let mut s = Session::new(); + s.execute("node.create", &json!({"type": "rectangle", "x": 100, "y": 300, "width": 50, "height": 40})).unwrap(); + // Six page pixels away: out of reach at zoom 2 (2.5), in reach at zoom 0.5 (10). + assert_eq!(page_snap(&s, true, 106.0, 2.0), None); + assert_eq!(page_snap(&s, true, 106.0, 0.5), Some(100.0)); + // A zoom of zero is clamped rather than dividing by zero. + assert_eq!(page_snap(&s, true, 100.0, 0.0), Some(100.0)); +} + +#[test] +fn a_frame_guide_snaps_to_its_box_its_children_and_nested_layers() { + let mut s = Session::new(); + let frame = id(s.execute("node.create", &json!({"type": "frame", "x": 100, "y": 50, "width": 300, "height": 200})).unwrap()); + let inner = id(s.execute("node.create", &json!({"type": "frame", "parent": frame, "x": 10, "y": 10, "width": 60, "height": 60})).unwrap()); + s.execute("node.create", &json!({"type": "rectangle", "parent": inner, "x": 20, "y": 20, "width": 20, "height": 20})).unwrap(); + let outside = id(s.execute("node.create", &json!({"type": "rectangle", "x": 1000, "y": 1000, "width": 20, "height": 20})).unwrap()); + let page = s.page().unwrap(); + let snap = |s: &Session, vertical: bool, offset: f64| snap_guide(s.doc().unwrap(), page, frame, vertical, offset, 1.0); + // The frame's own right edge is 300. + assert_eq!(snap(&s, true, 299.0), Some(300.0)); + // The nested rectangle sits at 30 in the frame; its left edge and centre (40) are targets. + assert_eq!(snap(&s, true, 31.0), Some(30.0)); + assert_eq!(snap(&s, true, 41.0), Some(40.0)); + // The layer outside the frame is not. + assert_eq!(snap(&s, true, 999.0), None); + // Hiding the nested rectangle takes its lines away. + let inner_rect = s.doc().unwrap().node(inner).unwrap().children[0]; + s.execute("node.set", &json!({"ids": [inner_rect], "props": {"visible": false}})).unwrap(); + assert_eq!(snap(&s, true, 31.0), None); + // A layer outside the frame still doesn't matter, and `outside` exists to prove the scope. + assert!(s.doc().unwrap().node(outside).is_some()); +} + +#[test] +fn a_frame_guide_snaps_along_the_frames_own_axes_when_it_is_turned() { + let mut s = Session::new(); + let frame = id(s.execute("node.create", &json!({"type": "frame", "x": 100, "y": 100, "width": 300, "height": 200})).unwrap()); + s.execute("node.create", &json!({"type": "rectangle", "parent": frame, "x": 40, "y": 20, "width": 20, "height": 20})).unwrap(); + s.execute("node.set", &json!({"ids": [frame], "props": {"rotation": 30.0}})).unwrap(); + let (doc, page) = (s.doc().unwrap(), s.page().unwrap()); + let t = Targets::gather_for_guide(doc, page, frame); + assert_ne!(t.space, Affine::IDENTITY, "guides snap in the frame's space"); + assert!(t.xs.iter().any(|(x, _, _)| near(*x, 40.0)), "{:?}", t.xs); + // The rotation rounds the offsets in the last bits, so compare with a tolerance. + assert!(snap_guide(doc, page, frame, true, 41.0, 1.0).is_some_and(|v| near(v, 40.0))); + assert!(snap_guide(doc, page, frame, false, 21.0, 1.0).is_some_and(|v| near(v, 20.0))); +} + +#[test] +fn a_guide_with_nothing_near_stays_where_it_is() { + let s = Session::new(); + let page = s.page().unwrap(); + let doc = s.doc().unwrap(); + assert_eq!(snap_guide(doc, page, page, true, 12.5, 1.0), None); + assert_eq!(snap_guide(doc, page, page, false, 12.5, 1.0), None); + // Non-finite input is refused rather than spread. + assert_eq!(snap_guide(doc, page, page, true, f64::NAN, 1.0), None); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0db81fd1..d37a24e6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -295,6 +295,8 @@ mod gesture_preview_tests; #[cfg(test)] mod gradient_shorthand_tests; #[cfg(test)] +mod guide_snap_tests; +#[cfg(test)] mod hide_publishing_tests; #[cfg(test)] mod history_behaviour_tests; diff --git a/crates/ui-egui/src/canvas/guides.rs b/crates/ui-egui/src/canvas/guides.rs index c3bb6589..909b761f 100644 --- a/crates/ui-egui/src/canvas/guides.rs +++ b/crates/ui-egui/src/canvas/guides.rs @@ -97,23 +97,28 @@ fn place(doc: &Document, owner: NodeId, index: usize, vertical: bool, offset: f6 Some(Placed { owner, index, vertical, offset, line: Some(guides::segment(doc, owner, vertical, offset)?) }) } -/// Guide `index` of `owner` (a page or frame) through page point `p`, its offset rounded to whole -/// pixels with `snap` (else to hundredths). -fn through(app: &FigApp, owner: NodeId, index: usize, vertical: bool, p: Point, snap: bool) -> Option { +/// Guide `index` of `owner` (a page or frame) through page point `p`. Its offset snaps to a nearby +/// layer edge or centre when `object_zoom` is set (`None`: object snapping off), then to whole +/// pixels with `pixel` (else to hundredths); a snapped offset keeps the edge exactly. +fn through(app: &FigApp, owner: NodeId, index: usize, vertical: bool, p: Point, pixel: bool, object_zoom: Option) -> Option { let ds = app.session.ds().ok()?; let v = guides::offset_at(&ds.doc, owner, vertical, p)?; - let offset = if snap { v.round() } else { (v * 100.0).round() / 100.0 }; + let offset = match object_zoom.and_then(|zoom| figcraft_engine::snap::snap_guide(&ds.doc, ds.page, owner, vertical, v, zoom)) { + Some(snapped) => snapped, + None if pixel => v.round(), + None => (v * 100.0).round() / 100.0, + }; if owner == ds.page { Some(Placed { owner, index, vertical, offset, line: None }) } else { place(&ds.doc, owner, index, vertical, offset) } } /// A guide dragged out of a ruler (`vertical`: the left one) and dropped at page point `p`: on the /// top-level frame under it, in the frame's coordinates and along its axis nearer the ruler's, or /// on the page. -fn drop_target(app: &FigApp, p: Point, vertical: bool, snap: bool) -> Option { +fn drop_target(app: &FigApp, p: Point, vertical: bool, pixel: bool, object_zoom: Option) -> Option { let ds = app.session.ds().ok()?; match guides::drop_owner(&ds.doc, ds.page, p) { - f if f != ds.page => through(app, f, usize::MAX, guides::frame_axis(&ds.doc, f, vertical), p, snap), - page => through(app, page, usize::MAX, vertical, p, snap), + f if f != ds.page => through(app, f, usize::MAX, guides::frame_axis(&ds.doc, f, vertical), p, pixel, object_zoom), + page => through(app, page, usize::MAX, vertical, p, pixel, object_zoom), } } @@ -182,18 +187,24 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool { return false; } let r = app.canvas.rect; - let (pressed, released, alt) = ctx.input(|i| { - (i.pointer.button_pressed(egui::PointerButton::Primary), i.pointer.button_released(egui::PointerButton::Primary), i.modifiers.alt) + let (pressed, released, alt, suspend) = ctx.input(|i| { + ( + i.pointer.button_pressed(egui::PointerButton::Primary), + i.pointer.button_released(egui::PointerButton::Primary), + i.modifiers.alt, + i.modifiers.command || i.modifiers.ctrl, + ) }); if let Some(drag) = ctx.data(|d| d.get_temp::(drag_id())) { let vertical = match drag { GuideDrag::New { vertical, .. } | GuideDrag::Move { vertical, .. } => vertical, }; let p = app.to_page(pos); - let snap = app.session.ds().map(|d| d.view.snap_pixel).unwrap_or(true); + let (pixel, object_zoom) = + app.session.ds().map(|d| (d.view.snap_pixel, (d.view.snap_objects && !suspend).then_some(d.view.zoom))).unwrap_or((true, None)); let at = match drag { - GuideDrag::New { vertical, .. } => drop_target(app, p, vertical, snap), - GuideDrag::Move { owner, index, vertical, .. } => through(app, owner, index, vertical, p, snap), + GuideDrag::New { vertical, .. } => drop_target(app, p, vertical, pixel, object_zoom), + GuideDrag::Move { owner, index, vertical, .. } => through(app, owner, index, vertical, p, pixel, object_zoom), }; // Alt: distances from the guide to the layers in the frame it crosses. let frame = drag.frame().or_else(|| at.map(|g| g.owner).filter(|o| app.session.ds().is_ok_and(|d| d.page != *o))); diff --git a/crates/ui-egui/src/guide_snap_tests.rs b/crates/ui-egui/src/guide_snap_tests.rs new file mode 100644 index 00000000..94278839 --- /dev/null +++ b/crates/ui-egui/src/guide_snap_tests.rs @@ -0,0 +1,97 @@ +//! Guides dragged out of a ruler snap to nearby layer edges and centres (Snap to Objects), on the +//! guide's own axis, and Cmd/Ctrl suspends that while the guides keep their pixel snapping. + +use egui::{Event, Modifiers, Pos2}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; + +fn id(v: serde_json::Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn rulers(mut s: Session) -> Ui { + s.execute("view.rulers", &json!({"on": true})).unwrap(); + Ui::new(s) +} + +fn guides(u: &Ui, owner: NodeId) -> Vec<(bool, f64)> { + let ds = u.app.session.ds().unwrap(); + ds.doc.node(owner).unwrap().guides().iter().map(|g| (g.vertical, g.offset)).collect() +} + +fn page(u: &Ui) -> NodeId { + u.app.session.ds().unwrap().page +} + +fn from_top_ruler(u: &Ui) -> Pos2 { + let r = u.app.canvas.rect; + Pos2::new(r.min.x + r.width() * 0.5, r.min.y + 5.0) +} + +fn from_left_ruler(u: &Ui) -> Pos2 { + let r = u.app.canvas.rect; + Pos2::new(r.min.x + 5.0, r.min.y + r.height() * 0.5) +} + +fn drag(u: &mut Ui, from: Pos2, to: Pos2) { + u.frame(vec![Event::PointerMoved(from)]); + u.button(from, true); + u.frame(vec![Event::PointerMoved(to)]); + u.button(to, false); +} + +fn drag_with(u: &mut Ui, from: Pos2, to: Pos2, m: Modifiers) { + u.frame_with(vec![Event::PointerMoved(from)], m); + u.frame_with(vec![Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: m }], m); + u.frame_with(vec![Event::PointerMoved(to)], m); + u.frame_with(vec![Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: m }], m); +} + +#[test] +fn a_guide_dragged_from_a_ruler_snaps_to_a_layers_edge() { + let mut s = Session::new(); + s.execute("node.create", &json!({"type": "rectangle", "x": 100, "y": 100, "width": 120, "height": 80})).unwrap(); + let mut u = rulers(s); + let start = from_top_ruler(&u); + let to = u.app.to_screen(200.0, 102.0); + drag(&mut u, start, to); + assert_eq!(guides(&u, page(&u)), vec![(false, 100.0)], "two pixels from the top edge"); +} + +#[test] +fn a_guide_dragged_over_a_frame_snaps_to_a_layer_inside_it() { + let mut s = Session::new(); + let frame = id(s.execute("node.create", &json!({"type": "frame", "x": 100, "y": 100, "width": 300, "height": 200})).unwrap()); + s.execute("node.create", &json!({"type": "rectangle", "parent": frame, "x": 10, "y": 10, "width": 40, "height": 40})).unwrap(); + let mut u = rulers(s); + let start = from_left_ruler(&u); + let to = u.app.to_screen(112.0, 200.0); + drag(&mut u, start, to); + assert_eq!(guides(&u, frame), vec![(true, 10.0)], "two pixels from the child's left edge"); +} + +#[test] +fn with_snap_to_objects_off_the_guide_keeps_its_pixel_rounding() { + let mut s = Session::new(); + s.execute("node.create", &json!({"type": "rectangle", "x": 100, "y": 100, "width": 120, "height": 80})).unwrap(); + s.execute("view.snapObjects", &json!({"on": false})).unwrap(); + let mut u = rulers(s); + let start = from_top_ruler(&u); + let to = u.app.to_screen(200.0, 102.0); + drag(&mut u, start, to); + assert_eq!(guides(&u, page(&u)), vec![(false, 102.0)], "rounded to the pixel, not the edge"); +} + +#[test] +fn cmd_or_ctrl_suspends_guide_snapping() { + let mut s = Session::new(); + s.execute("node.create", &json!({"type": "rectangle", "x": 100, "y": 100, "width": 120, "height": 80})).unwrap(); + let mut u = rulers(s); + let start = from_top_ruler(&u); + let to = u.app.to_screen(200.0, 102.0); + drag_with(&mut u, start, to, Modifiers::CTRL); + assert_eq!(guides(&u, page(&u)), vec![(false, 102.0)]); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8d5d3e1b..9eed0438 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -90,6 +90,8 @@ mod gradient_shorthand_tests; #[cfg(test)] mod guide_release_tests; #[cfg(test)] +mod guide_snap_tests; +#[cfg(test)] mod home_recents_tests; #[cfg(test)] mod home_tests; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2604cfc9..217ad38f 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-10-25 · **Change:** minor (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; 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; 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; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; 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) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (guides snap to layers when dragged; 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; 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; 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; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; 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) · **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: @@ -63,7 +63,7 @@ interaction feel not yet compared. | Drag threshold; double-click into groups and text | done | a press moves or resizes only once the pointer has travelled 3 px on screen, so a click or double-click made with an unsteady hand drags nothing; a double-click goes one level into a group and the next one edits the text under it, wherever on the text it lands, with all of it selected (a double-click in the text being edited selects the word) (`double_click_tests`); Enter on a selected text does the same (all of it selected, typing replaces it) and Escape leaves editing with the layer still selected (`text_enter_tests`) | | Scale tool (K): handles about an anchor, live factor, panel | done | | | Nudge 1 / Shift 10 (preference), Cmd+arrows resize | done | arrows reorder auto layout children | -| Smart guides: edges, centres, equal spacing (between two layers or continuing a row's gap, labelled), spacing labels, parent, layout grid, ruler guides | done | red, `snap.rs`; thresholds not yet compared with Figma's | +| Smart guides: edges, centres, equal spacing (between two layers or continuing a row's gap, labelled), spacing labels, parent, layout grid, ruler guides | done | red, `snap.rs`; a guide dragged out of a ruler or moved snaps to layer edges and centres too (Snap to Objects; Cmd/Ctrl suspends), on its own axis only; page guides see the page's top-level layers, frame guides the frame's box, layout grids and every layer in it, along a rotated frame's own axes; thresholds not yet compared with Figma's (`guide_snap_tests`) | | Ctrl to suspend snapping while dragging (macOS) | done | Control on macOS (Cmd keeps working), Ctrl elsewhere; moving, resizing and drawing | | Measure (Alt hover, Cmd+Alt nested, Shift+Alt padding/gaps) | done | labels never overlap; the arrows still nudge while measuring (Shift: the big step) and the distances follow; the selection's handles and size label hide while measuring (`measure_nudge_tests`, #894) | | Pixel grid snapping, pixel preview, 2x preview | done | moves, resizes and new shapes land on whole pixels; the pixel grid shows from 400% (`view_behaviour_tests`) | @@ -183,6 +183,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged | | 2026-10-20 | minor | Measuring while nudging (#894): with Alt held over another layer, the arrows nudge the selection (Shift: the big step) and the distances follow every nudge (Alt+arrows used to do nothing); while measuring, the selection's handles and size label hide and both layers keep their outlines (`measure_nudge_tests`). Numbers unchanged | | 2026-10-18 | minor | Export: an "XML" format (Android Vector Drawable, said on hover) in the Export section's format menu, with "Include id attribute" and a compatibility choice (best effort, exact only, ignore unsupported) in its settings, and an XML preset in File ▸ Export…; the export toast says when content was changed or left out. Numbers unchanged | +| 2026-10-29 | minor | Guides snap to layers when dragged: a guide pulled from a ruler or moved snaps to nearby layer edges and centres (Snap to Objects; Cmd/Ctrl suspends), on its own axis only, within five screen pixels at any zoom; page guides see the page's top-level layers, frame guides the frame's box, layout grids and every layer in it, along a rotated frame's own axes; a snapped guide keeps the edge exactly while an unsnapped one still rounds to the pixel (`guide_snap_tests`). Summary and hours unchanged | | 2026-10-18 | minor | A rename (layers double-click, F2, File ▸ Rename, a page) selects the whole name in the frame its field first draws, so the first key replaces the name instead of being appended. Checked in the app on Windows over the control channel | | 2026-10-17 | minor | Escape or Enter while the pen draws ends the path and keeps its vector in edit mode with the pen, so the next path is drawn into the same layer (unconnected); another Escape leaves edit mode (#760). Entering edit mode on a layer that already is a vector no longer records an empty undo step. Numbers unchanged | | 2026-10-17 | minor | Text layers keep the platform IME on for every frame of an edit, not only frames with input (an idle frame turned it off and macOS dropped each composition, #747); the candidate window opens at the caret instead of the canvas corner; keys pressed while composing go to the IME | From f0996645b1496aba635356d74a118317dd96b049 Mon Sep 17 00:00:00 2001 From: samhyun Date: Thu, 29 Oct 2026 15:34:14 +0900 Subject: [PATCH 02/63] M8: the browser applies input sent over its control channel `ui.input` over `window.figcraft` (the page's control channel, `?control`) never did anything in the browser: the batch was queued for the next frame as on the desktop, but the browser shell has no `raw_input_hook`, the eframe callback that hands a queued batch to the frame about to draw (`windows::take_input`). The events stayed in their queue and the request's promise never settled, so an agent driving the page could run commands and take screenshots but not point, click or type. The browser shell now has the hook the desktop app has: the batch goes into the frame's raw input, is applied by it and is acknowledged once the frame is presented, as on the desktop. Checked in Chrome on the release web build: `ui.input` with a key (O, R, F: the ellipse, rectangle and frame tools) and with a pointer gesture (a drag with the rectangle tool: a rectangle drawn) resolves and takes effect, where on `main` the same requests time out and change nothing. Co-Authored-By: Claude Fable 5.1 --- apps/figcraft-web/src/web.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index de17cc36..a938bf0f 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -85,6 +85,15 @@ impl eframe::App for WebShell { figcraft_ui_egui::windows::guard(&mut self.fig, ctx, |fig| fig.background_tick(ctx)); } + fn raw_input_hook(&mut self, _ctx: &egui::Context, raw_input: &mut egui::RawInput) { + // Pointer and key events sent over the control channel (`ui.input`), for the frame about + // to draw: the batch is applied by this frame and acknowledged once it is presented, as on + // the desktop (`apps/figcraft/src/main.rs`). Without this hook the batch sat in its queue + // and its promise never settled. + let events = figcraft_ui_egui::windows::take_input(&mut self.fig, raw_input.viewport_id); + raw_input.events.extend(events); + } + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { self.fig.control_queue.extend(crate::api::take_requests()); self.fig.ui(ui); From eedc66bddc3a58c362761e33cf0e2cd42a230dc2 Mon Sep 17 00:00:00 2001 From: Ahmed Mahmoud Date: Thu, 29 Oct 2026 09:47:11 +0300 Subject: [PATCH 03/63] M1: address guide snapping review findings Hidden containers hide their whole branch from snapping, the 5 px reach is converted through the owner's scaled axis so a zoomed frame snaps at 5 screen pixels, and a click on a guide only selects it. Tests cover all three in guide_snap_tests.rs. Signed-off-by: Ahmed Mahmoud --- crates/engine/src/canvas/snap.rs | 24 +++++++++++++----- crates/engine/src/guide_snap_tests.rs | 35 ++++++++++++++++++++++++++ crates/ui-egui/src/canvas/guides.rs | 11 +++++--- crates/ui-egui/src/guide_snap_tests.rs | 13 ++++++++++ 4 files changed, 74 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/canvas/snap.rs b/crates/engine/src/canvas/snap.rs index 3cd832dc..d1d7ade7 100644 --- a/crates/engine/src/canvas/snap.rs +++ b/crates/engine/src/canvas/snap.rs @@ -159,13 +159,21 @@ impl Targets { t.ys.push((y, b.x0, b.x1)); } } - // Everything in the frame. Nested layers count, so a guide over a group reaches its parts. - for id in doc.subtree(owner).into_iter().skip(1) { - if doc.node(id).is_some_and(|n| n.visible) - && let Some(b) = bbox(id) - { + // Everything in the frame. Nested layers count, so a guide over a group reaches its parts; a + // hidden layer hides its whole branch, so a visible child of a hidden group isn't a target. + let mut stack: Vec<(NodeId, bool)> = doc.node(owner).map(|n| n.children.iter().rev().map(|c| (*c, true)).collect()).unwrap_or_default(); + let mut seen = 0; + while let Some((id, visible)) = stack.pop() { + seen += 1; + if seen > 5_000_000 { + break; + } + let Some(n) = doc.node(id) else { continue }; + let visible = visible && n.visible; + if visible && let Some(b) = bbox(id) { t.add_box(b); } + stack.extend(n.children.iter().rev().map(|c| (*c, visible))); } t } @@ -402,7 +410,11 @@ impl Targets { /// its own axis, within `SNAP_PX` screen pixels at `zoom`. Returns the new offset in `owner`'s /// coordinates, or `None` when nothing is in reach. pub fn snap_guide(doc: &Document, page: NodeId, owner: NodeId, vertical: bool, offset: f64, zoom: f64) -> Option { - let threshold = SNAP_PX / zoom.max(1e-6); + // The guide moves along the owner's own axis (x for a vertical guide, y for a horizontal one), + // which the owner's transform may scale: convert the screen-pixel reach into owner units. + let [a, b, c, d, _, _] = doc.world_transform(owner).as_coeffs(); + let axis = if vertical { a.hypot(b) } else { c.hypot(d) }; + let threshold = SNAP_PX / (zoom.max(1e-6) * axis.max(1e-6)); Targets::gather_for_guide(doc, page, owner).snap_axis(vertical, offset, threshold).map(|d| offset + d).filter(|v| v.is_finite()) } diff --git a/crates/engine/src/guide_snap_tests.rs b/crates/engine/src/guide_snap_tests.rs index 3402ef55..4c30f129 100644 --- a/crates/engine/src/guide_snap_tests.rs +++ b/crates/engine/src/guide_snap_tests.rs @@ -88,6 +88,41 @@ fn a_frame_guide_snaps_along_the_frames_own_axes_when_it_is_turned() { assert!(snap_guide(doc, page, frame, false, 21.0, 1.0).is_some_and(|v| near(v, 20.0))); } +#[test] +fn a_hidden_container_hides_its_children_from_snapping() { + let mut s = Session::new(); + let frame = id(s.execute("node.create", &json!({"type": "frame", "x": 100, "y": 50, "width": 300, "height": 200})).unwrap()); + let inner = id(s.execute("node.create", &json!({"type": "frame", "parent": frame, "x": 10, "y": 10, "width": 60, "height": 60})).unwrap()); + let child = id(s.execute("node.create", &json!({"type": "rectangle", "parent": inner, "x": 20, "y": 20, "width": 20, "height": 20})).unwrap()); + let page = s.page().unwrap(); + let snap = |s: &Session, offset: f64| snap_guide(s.doc().unwrap(), page, frame, true, offset, 1.0); + // The inner frame's left edge is 10 in the frame; the nested rectangle's is 30. + assert_eq!(snap(&s, 11.0), Some(10.0)); + assert_eq!(snap(&s, 31.0), Some(30.0)); + // Hiding the inner frame hides its branch: the child's own flag stays true, but its edge is + // not a target any more (nor is the hidden frame's). + s.execute("node.set", &json!({"ids": [inner], "props": {"visible": false}})).unwrap(); + assert!(s.doc().unwrap().node(child).unwrap().visible); + assert_eq!(snap(&s, 11.0), None); + assert_eq!(snap(&s, 31.0), None); +} + +#[test] +fn the_reach_is_five_screen_pixels_across_a_scaled_frame() { + let mut s = Session::new(); + let frame = id(s.execute("node.create", &json!({"type": "frame", "x": 100, "y": 50, "width": 300, "height": 200})).unwrap()); + s.execute("node.create", &json!({"type": "rectangle", "parent": frame, "x": 100, "y": 10, "width": 20, "height": 20})).unwrap(); + // The frame is drawn at twice its own size, so an owner unit is two screen pixels at zoom 1. + s.doc_mut().unwrap().node_mut(frame).unwrap().transform = Affine::scale(2.0); + let page = s.page().unwrap(); + let snap = |s: &Session, offset: f64, zoom: f64| snap_guide(s.doc().unwrap(), page, frame, true, offset, zoom); + // Five screen pixels are 2.5 frame units: two snaps to the edge at 100, three doesn't. + assert_eq!(snap(&s, 102.0, 1.0), Some(100.0)); + assert_eq!(snap(&s, 103.0, 1.0), None); + // Zooming in halves the owner-space reach again (1.25 frame units at 2x zoom). + assert_eq!(snap(&s, 102.0, 2.0), None); +} + #[test] fn a_guide_with_nothing_near_stays_where_it_is() { let s = Session::new(); diff --git a/crates/ui-egui/src/canvas/guides.rs b/crates/ui-egui/src/canvas/guides.rs index 909b761f..725957f9 100644 --- a/crates/ui-egui/src/canvas/guides.rs +++ b/crates/ui-egui/src/canvas/guides.rs @@ -23,7 +23,8 @@ enum GuideDrag { /// Dragging a new guide out of a ruler. New { vertical: bool, frame: Option }, /// Moving guide `index` of `owner` (a page or frame); `copy` (Alt at the press) drops a new one. - Move { owner: NodeId, index: usize, vertical: bool, copy: bool, frame: Option }, + /// `from` is where it was pressed, so a click without movement only selects it. + Move { owner: NodeId, index: usize, vertical: bool, copy: bool, frame: Option, from: Pos2 }, } impl GuideDrag { @@ -204,7 +205,11 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool { app.session.ds().map(|d| (d.view.snap_pixel, (d.view.snap_objects && !suspend).then_some(d.view.zoom))).unwrap_or((true, None)); let at = match drag { GuideDrag::New { vertical, .. } => drop_target(app, p, vertical, pixel, object_zoom), - GuideDrag::Move { owner, index, vertical, .. } => through(app, owner, index, vertical, p, pixel, object_zoom), + // A click only selects the guide: object snapping starts once the pointer moves. + GuideDrag::Move { owner, index, vertical, from, .. } => { + let moved = (pos - from).length() > 0.5; + through(app, owner, index, vertical, p, pixel, if moved { object_zoom } else { None }) + } }; // Alt: distances from the guide to the layers in the frame it crosses. let frame = drag.frame().or_else(|| at.map(|g| g.owner).filter(|o| app.session.ds().is_ok_and(|d| d.page != *o))); @@ -283,7 +288,7 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool { app.ui.context_menu_shown = false; } else { ctx.data_mut(|d| { - d.insert_temp(drag_id(), GuideDrag::Move { owner: g.owner, index: g.index, vertical: g.vertical, copy: alt, frame }) + d.insert_temp(drag_id(), GuideDrag::Move { owner: g.owner, index: g.index, vertical: g.vertical, copy: alt, frame, from: pos }) }); } } diff --git a/crates/ui-egui/src/guide_snap_tests.rs b/crates/ui-egui/src/guide_snap_tests.rs index 94278839..edf8cb11 100644 --- a/crates/ui-egui/src/guide_snap_tests.rs +++ b/crates/ui-egui/src/guide_snap_tests.rs @@ -85,6 +85,19 @@ fn with_snap_to_objects_off_the_guide_keeps_its_pixel_rounding() { assert_eq!(guides(&u, page(&u)), vec![(false, 102.0)], "rounded to the pixel, not the edge"); } +#[test] +fn a_click_on_a_guide_only_selects_it() { + let mut s = Session::new(); + s.execute("node.create", &json!({"type": "rectangle", "x": 100, "y": 100, "width": 120, "height": 80})).unwrap(); + s.execute("guide.add", &json!({"vertical": false, "offset": 102})).unwrap(); + let mut u = rulers(s); + // Two pixels above the rectangle's top edge, but a click without movement leaves it there. + let at = u.app.to_screen(500.0, 102.0); + u.button(at, true); + u.button(at, false); + assert_eq!(guides(&u, page(&u)), vec![(false, 102.0)], "a click must not snap it to the edge at 100"); +} + #[test] fn cmd_or_ctrl_suspends_guide_snapping() { let mut s = Session::new(); From 31d7d5ca7e1952aff4cacf20db87d44c63174e5c Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:06:47 +0100 Subject: [PATCH 04/63] Keep full precision in export size labels so they parse back Found while testing crates/doc/src/props.rs:1341 (ExportSize::label, via trim_num at crates/doc/src/text.rs:62) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/props.rs | 11 ++++++----- crates/engine/src/import_export_tests.rs | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index 0d4361b2..403620d6 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -1337,13 +1337,14 @@ impl ExportSize { (value.is_finite() && value > 0.0 && value <= max).then_some(self) } - /// "2x", "512w", "512h". + /// "2x", "0.125x", "512w", "512h". Not rounded, so `parse` reads it back as the same size. pub fn label(self) -> String { - let n = |v: f64| crate::text::trim_num(v); + // `{}` prints the shortest text that parses back to the same `f64`, and whole numbers + // without a fraction ("2", not "2.0"). match self { - ExportSize::Scale { value } => format!("{}x", n(value)), - ExportSize::Width { value } => format!("{}w", n(value)), - ExportSize::Height { value } => format!("{}h", n(value)), + ExportSize::Scale { value } => format!("{value}x"), + ExportSize::Width { value } => format!("{value}w"), + ExportSize::Height { value } => format!("{value}h"), } } diff --git a/crates/engine/src/import_export_tests.rs b/crates/engine/src/import_export_tests.rs index 5560e9fd..a60f85b0 100644 --- a/crates/engine/src/import_export_tests.rs +++ b/crates/engine/src/import_export_tests.rs @@ -232,3 +232,21 @@ fn export_sizes_parse_and_label() { assert_eq!(e(ExportSize::Scale { value: 2.0 }, "-big", ExportFormat::Png).file_suffix(), "-big"); assert_eq!(e(ExportSize::Width { value: 64.0 }, "", ExportFormat::Png).file_suffix(), ""); } + +#[test] +fn export_size_label_parses_back_exactly() { + for want in [ + ExportSize::Scale { value: 0.125 }, + ExportSize::Scale { value: 0.004 }, + ExportSize::Scale { value: 0.001 }, + ExportSize::Scale { value: 63.99 }, + ExportSize::Scale { value: 1.0 / 3.0 }, + ExportSize::Width { value: 512.125 }, + ExportSize::Height { value: 0.005 }, + ] { + assert_eq!(ExportSize::parse(&want.label()), Some(want), "{}", want.label()); + } + assert_eq!(ExportSize::Scale { value: 0.125 }.label(), "0.125x"); + assert_eq!(ExportSize::Scale { value: 2.0 }.label(), "2x"); + assert_eq!(ExportSize::Width { value: 512.0 }.label(), "512w"); +} From 24a512793ca825a92a88d79c1fdcbea26a88a270 Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Thu, 29 Oct 2026 11:16:33 +0200 Subject: [PATCH 05/63] M4: a drag anywhere in vector edit mode draws a marquee (#1508) A press more than a few pixels from the edited shape left edit mode at once, so a drag over another layer moved that layer. The press now starts a marquee; released without a drag it still leaves edit mode and replays the click to the normal tool. --- crates/engine/src/tools.rs | 2 +- crates/engine/src/vectoredit/pointer.rs | 25 +++++++++++++++---------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 86feed4f..763b60ec 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -212,7 +212,7 @@ pub struct PointerEvent { } /// The pointer is still within [`DRAG_SLOP_PX`] of where it was pressed. -fn within_slop(ev: &PointerEvent, start: Point) -> bool { +pub(crate) fn within_slop(ev: &PointerEvent, start: Point) -> bool { (ev.p() - start).hypot() * ev.zoom.max(1e-6) < DRAG_SLOP_PX } diff --git a/crates/engine/src/vectoredit/pointer.rs b/crates/engine/src/vectoredit/pointer.rs index 4721d6bd..f9cb0593 100644 --- a/crates/engine/src/vectoredit/pointer.rs +++ b/crates/engine/src/vectoredit/pointer.rs @@ -13,7 +13,7 @@ use super::regions::region_hit; use super::transform::{self, BoxHit, PointBox}; use super::{EditPart, VHit, VTool, VectorEdit, edit, editing, hit, hit_any, local_offset, network, ops, points_of, selected_points, set_state}; use crate::snap::Targets; -use crate::tools::{Cursor, Gesture, Handle, PenCursor, PointerEvent, PointerKind}; +use crate::tools::{Cursor, Gesture, Handle, PenCursor, PointerEvent, PointerKind, within_slop}; use crate::{Result, Session}; /// An in-progress edit-mode gesture (cheap to clone: snap targets are shared). @@ -67,10 +67,13 @@ pub(crate) enum VGesture { moved: bool, smooth: bool, }, - /// Marquee and lasso: `base` is each edited layer's selection to add to. + /// Marquee and lasso: `base` is each edited layer's selection to add to. `leave` is a press + /// away from every edited layer: released without a drag, it leaves edit mode and goes to the + /// normal tool. Marquee { start: Point, base: Vec, + leave: Option, }, Lasso { points: Vec, @@ -661,17 +664,14 @@ impl Session { } } None => { - // Outside every edited layer (and not shift): leave edit mode. + // Outside every edited layer (and not shift): a click leaves edit mode, a drag marquees. let inside = ve.parts().iter().any(|p| doc.world_render_bounds(p.id).is_some_and(|b| b.inflate(tol * 4.0, tol * 4.0).contains(ev.p()))); - if !inside && !ev.shift { - super::end(self)?; - return Ok(false); - } if !ev.shift { set_state(self, VectorEdit::clear_selection)?; } - self.vgesture(VGesture::Marquee { start: ev.p(), base: Self::select_base(ve, ev.shift) }); + let leave = (!inside && !ev.shift).then(|| ev.clone()); + self.vgesture(VGesture::Marquee { start: ev.p(), base: Self::select_base(ve, ev.shift), leave }); } }, VTool::Bend => match h { @@ -695,7 +695,7 @@ impl Session { self.begin_interaction("Bend")?; self.vgesture(VGesture::BendSegment { seg, t }); } - None => self.vgesture(VGesture::Marquee { start: ev.p(), base: Self::select_base(ve, ev.shift) }), + None => self.vgesture(VGesture::Marquee { start: ev.p(), base: Self::select_base(ve, ev.shift), leave: None }), }, VTool::Pen => { let at = if h.is_none() && !ev.shift { self.snap_pen(ev, Some(id), &[])?.0 } else { ev.p() }; @@ -908,7 +908,7 @@ impl Session { self.preview(|doc, _| edit(doc, id, |net| ops::make_smooth(net, v, d)))?; self.vgesture(VGesture::BendVertex { v, origin, moved: true, smooth }); } - VGesture::Marquee { start, base } => { + VGesture::Marquee { start, base, .. } => { let r = Rect::from_points(start, ev.p()); self.tool.overlay.marquee = Some([r.x0, r.y0, r.x1, r.y1]); self.select_parts(&base, |doc, id| super::vertices_in(doc, id, r), false)?; @@ -1025,6 +1025,11 @@ impl Session { VGesture::Handle { .. } | VGesture::BendSegment { .. } | VGesture::Pen { .. } | VGesture::BoxRotate { .. } => { self.commit_interaction()? } + VGesture::Marquee { start, leave: Some(press), .. } if within_slop(ev, start) => { + super::end(self)?; + self.pointer(&press)?; + return self.pointer(ev); + } // The active layer moves to one with points selected when it has none. VGesture::Marquee { .. } | VGesture::Lasso { .. } => set_state(self, |e| { if e.vertices.is_empty() From 3e0e00c3fd23c05649ce588d40252e04ac96d59d Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Thu, 29 Oct 2026 11:16:33 +0200 Subject: [PATCH 06/63] test(vector): a marquee starts anywhere on the canvas (#1508) --- crates/engine/src/vector_behaviour_tests.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/engine/src/vector_behaviour_tests.rs b/crates/engine/src/vector_behaviour_tests.rs index fb734085..1a3b9b42 100644 --- a/crates/engine/src/vector_behaviour_tests.rs +++ b/crates/engine/src/vector_behaviour_tests.rs @@ -270,6 +270,27 @@ fn selecting_points_and_segments() { assert_eq!(page_pos(&s, id, tl), Point::new(1.0, 0.0), "the drag is one undo step"); } +#[test] +fn a_marquee_starts_anywhere_on_the_canvas() { + // #1508: a drag far from the edited shape, over another layer, marquees its points; a click + // there still leaves edit mode and selects that layer. + let mut s = Session::new(); + let bg = id_of(&ex(&mut s, "shape.rectangle", json!({"x": -500, "y": -500, "width": 1000, "height": 1000}))["id"]).unwrap(); + let id = square(&mut s, true); + let (tl, tr) = (point_at(&s, id, (0.0, 0.0)), point_at(&s, id, (100.0, 0.0))); + let bg_x = |s: &Session| s.doc().unwrap().world_transform(bg).translation().x; + drag(&mut s, (-200.0, -200.0), (110.0, 10.0), NONE); + let mut got = editing(&s).expect("a drag away from the shape stays in edit mode").vertices; + got.sort_unstable(); + let mut want = vec![tl, tr]; + want.sort_unstable(); + assert_eq!(got, want, "the marquee selects the points inside it"); + assert_eq!(bg_x(&s), -500.0, "the layer under the drag stays put"); + click(&mut s, (-200.0, -200.0), NONE); + assert!(editing(&s).is_none(), "a click away from the shape leaves edit mode"); + assert_eq!(s.selection(), vec![bg], "the click selects the layer under it"); +} + #[test] fn deleting_points_vs_delete_and_heal() { // (key, Shift, points, segments, closed afterwards, derivation) From 374ebdb0b91df3989dbc953ad55d4fbcf15d10a3 Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Thu, 29 Oct 2026 11:24:30 +0200 Subject: [PATCH 07/63] docs: record the vector edit marquee fix (#1508) --- ROADMAP.md | 1 + docs/roadmap.md | 1 + 2 files changed, 2 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 69d3bf73..3f709e54 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -329,6 +329,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | A marquee from anywhere in vector edit mode (#1508): a press more than a few pixels from the edited shape used to leave edit mode at once, so a drag over another layer moved that layer; it now draws a marquee that selects the points inside it. A click there still leaves edit mode and selects what it lands on. Numbers unchanged | | 2026-10-24 | minor | Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 97% → 98% breadth, 76% → 78% ready, 2–4 h → 1–2 h; headline numbers unchanged at this rounding | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Boards row added outside the weighted numbers; headline numbers unchanged at this rounding | | 2026-10-15 | minor | Auto-save (#305, #296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only, the browser has no files to save to) saves each open file that has a path 3 s after its last change, so steady editing doesn't save every few seconds, and never during a gesture, a live text preview or while a version is shown. The file is encoded on a thread of its own and written as of that moment (edits made meanwhile stay unsaved); a failed save says why in a toast and is tried again a minute later, and version history checkpoints keep coming with it on. Numbers unchanged | diff --git a/docs/roadmap.md b/docs/roadmap.md index 8e093ac9..29608c3f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | A marquee from anywhere in vector edit mode (#1508): a press more than a few pixels from the edited shape used to leave edit mode at once, so a drag over another layer moved that layer; it now draws a marquee that selects the points inside it. A click there still leaves edit mode and selects what it lands on. Numbers unchanged | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Milestone M12 Boards added; AI features move to M13 | | 2026-10-15 | minor | Auto-save (#305, #296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only, the browser has no files to save to) saves each open file that has a path 3 s after its last change, so steady editing doesn't save every few seconds, and never during a gesture, a live text preview or while a version is shown. The file is encoded on a thread of its own and written as of that moment (edits made meanwhile stay unsaved); a failed save says why in a toast and is tried again a minute later, and version history checkpoints keep coming with it on. Numbers unchanged | | 2026-10-15 | minor | A text box by dragging (#363, #360): a drag with the Text tool makes a fixed-size text box the size of the drag (at least one line tall); a click, or a drag shorter than the drag threshold, still makes auto-width text. Numbers unchanged | From 07681afebedab24a67b9f954f1f58fb01c557b8d Mon Sep 17 00:00:00 2001 From: BFlat Date: Thu, 29 Oct 2026 17:04:29 +0700 Subject: [PATCH 08/63] M6: .fig import keeps the file's saved line breaks while the text is unedited (#1236) (#1519) * M6: .fig import keeps the file's saved line breaks while the text and its box are unedited (#1236) The line breaker already keeps trailing spaces and the last glyph's letter spacing out of the fit test and doesn't round box widths; imported text wrapped a word early or mid-word because local fonts measure slightly differently from the file's. The import keeps the saved line starts (derivedTextData) on the text with the box width and a stable key of its layout content; layout follows them while both match and no line overflows its box by more than 3% (+1 px). * M6: local check that imported text wraps at the line breaks its file saved (#1236) Runs only when FIGCRAFT_FIG_SAMPLES names a folder of .fig files: each fixed-width text with saved breaks wraps there once imported, or falls back by the overflow slack; prints the counts. --- ROADMAP.md | 3 +- crates/doc/src/text.rs | 52 +++++++++ crates/engine/src/text_metrics_local_tests.rs | 52 +++++++++ crates/format/src/fig.rs | 2 + crates/format/src/fig/map/component.rs | 1 + crates/format/src/fig/map/text.rs | 41 +++++++ crates/format/src/fig/text_wrap_tests.rs | 102 ++++++++++++++++++ crates/format/src/rest/text.rs | 11 ++ crates/text/src/layout.rs | 62 ++++++++--- crates/text/src/lib.rs | 2 + crates/text/src/saved_breaks_tests.rs | 95 ++++++++++++++++ docs/file-format-parity.md | 4 +- docs/gaps.md | 17 ++- docs/roadmap.md | 3 +- 14 files changed, 429 insertions(+), 18 deletions(-) create mode 100644 crates/format/src/fig/text_wrap_tests.rs create mode 100644 crates/text/src/saved_breaks_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 59be86c5..3889a2b3 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-29 · **Change:** minor (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-10-29 · **Change:** minor (`.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 @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M6, #1236 (gap 1): imported `.fig` text keeps the line breaks the file saved with it (`derivedTextData`), so a font whose advances differ slightly from the file's (another build of the family, a stand-in) no longer wraps a word early or splits one in a narrow box. The breaks hold only while the box width and everything that moves a break are as saved (characters, fonts, sizes, spacing, case, axes, features, lists, paragraph settings; paints, links and vertical settings don't count), and are given up where a line would come out more than 3% (+1 px) wider than its box; they are kept in `.figcraft`. The line breaker was checked and is unchanged: trailing spaces and the letter spacing after a line's last glyph already stay out of the fit test, box widths aren't rounded and the tolerance is 1e-6 px, so the differences come from the fonts. Synthetic tests in `saved_breaks_tests` and `fig::text_wrap_tests`; not checked on real files. Numbers unchanged | | 2026-10-29 | minor | #754, #1025: the Missing fonts window offers Download for a family the suite's font store has (Google Fonts, Noto: craft-libs' `craft-font-store`, its pinned indexes compiled in). The first Download asks the user to allow downloading fonts (off until then; the setting is the Craft apps' shared one); the files download on a thread of their own, each checked against the index's size and sha256 before it is installed into the shared font folder, which the font scan now reads; the row shows progress or why it failed, and once installed the file's text fits the real font and the family leaves the list. `font.download {family}` and `font.downloads` are commands, but no command turns downloading on; agents install only while the user has it on. Desktop only. No cancel, no turning it off from FigCraft, no substitute picker. Tests: `fonts::download::tests`, `missing_fonts_tests`. Numbers unchanged | | 2026-10-29 | minor | Web wasm size (#1472): the release wasm is 23,915,213 bytes (22.8 MiB), down from 25,982,540 (24.8 MiB), 1.2 MiB under the gate. Catalog rows hold their plural forms behind a reference (two words, not eight) and the browser build compiles with wasm SIMD, so the glyph rasterizer's scalar fallback is left out; the 24 MiB gate in `packaging/web/package.sh` is restored. The browser now needs WebAssembly SIMD (Safari 16.4 or later). Numbers unchanged. | | 2026-10-29 | minor | Shadows of images with transparent pixels (M3, #1289): a drop shadow is cast from the picture's alpha and hidden only behind its pixels, not its whole box (a transparent PNG's shadow was cut away inside its bounding box), unless shown behind transparent areas; its inner shadow shades the inside of its alpha, not the box's edge; the same for Fit images, crops smaller than the box and pattern fills. Opaque pictures that fill their box keep their outline (spread still offsets it exactly); each image is scanned for transparency once. Canvas, PNG, SVG (knocked out by the layer's alpha; an inner shadow embeds a render) and PDF (`image_shadow_tests` in render and svg). Numbers unchanged | diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index a000447f..b7c5e4cc 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -445,6 +445,21 @@ pub struct TextData { /// data small. #[serde(skip_serializing_if = "Option::is_none")] pub path: Option>, + /// Where the file this text came from wrapped its lines (an imported `.fig`'s saved layout). + /// Layout follows them only while the box width and everything that moves a line break are + /// as they were saved, so an edit that could change the wrap drops them. + #[serde(skip_serializing_if = "Option::is_none")] + pub saved_breaks: Option>, +} + +/// Line breaks a file saved with a text: the chars that start a wrapped line (not those after a +/// hard break), for the box width and the content ([`TextData::layout_key`]) they were made for. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct SavedBreaks { + pub starts: Vec, + pub width: f64, + pub for_text: u64, } /// Text on a path: the baseline follows `d`. The text starts at `start` (aligned there by the @@ -503,6 +518,43 @@ impl TextData { Self { characters: characters.into(), ..Default::default() } } + /// A stable hash of what decides where lines break: everything but paints, links, style links, + /// decorations, vertical settings and the saved breaks themselves (FNV-1a over the JSON, so it + /// stays the same across builds and can be saved). + pub fn layout_key(&self) -> u64 { + let clear = |s: &mut TextStyle| { + (s.fills, s.link, s.style_id, s.decoration, s.decoration_look) = (None, None, None, TextDecoration::None, None); + }; + let mut t = TextData { + align_v: TextAlignV::default(), + paragraph_spacing: 0.0, + list_spacing: 0.0, + leading_trim: LeadingTrim::default(), + max_lines: 0, + saved_breaks: None, + ..self.clone() + }; + clear(&mut t.style); + t.runs.iter_mut().for_each(|r| clear(&mut r.style)); + t.normalize(); + serde_json::to_vec(&t).unwrap_or_default().iter().fold(0xcbf2_9ce4_8422_2325, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x0100_0000_01b3)) + } + + /// Keep `starts` (chars that start a wrapped line) as this text's saved line breaks in a box + /// `width` wide. + pub fn save_breaks(&mut self, starts: Vec, width: f64) { + self.saved_breaks = Some(Box::new(SavedBreaks { starts, width, for_text: self.layout_key() })); + } + + /// The saved line starts (empty: no wrapped line) while they still hold: the same box width and + /// layout content, and starts increasing inside the text. + pub fn saved_breaks_for(&self, width: f64) -> Option<&[usize]> { + let b = self.saved_breaks.as_deref()?; + let inside = b.starts.first().is_none_or(|s| *s > 0) && b.starts.last().is_none_or(|s| *s < self.char_count()); + let same_box = (b.width - width).abs() <= 1e-6 * width.abs().max(1.0); + (inside && same_box && b.starts.is_sorted_by(|a, b| a < b) && b.for_text == self.layout_key()).then_some(b.starts.as_slice()) + } + /// How far text on a path paints past its path's box (glyphs stand on the path, lines after /// the first run beside it): about one and a half of the largest size per line; 0 off a path. pub fn path_reach(&self) -> f64 { diff --git a/crates/engine/src/text_metrics_local_tests.rs b/crates/engine/src/text_metrics_local_tests.rs index c4159ed4..34f25fac 100644 --- a/crates/engine/src/text_metrics_local_tests.rs +++ b/crates/engine/src/text_metrics_local_tests.rs @@ -15,6 +15,58 @@ fn of_own_line_height(n: &Node) -> Option { Some(m) } +/// The chars that start a wrapped line (not those after a hard break) in `t` laid out `w` wide. +fn soft_starts(t: &figcraft_doc::TextData, w: f64) -> Vec { + let chars: Vec = t.characters.chars().collect(); + let l = figcraft_text::layout(t, Some(w), None); + l.lines + .iter() + .skip(1) + .map(|ln| ln.char_start) + .filter(|s| !s.checked_sub(1).and_then(|i| chars.get(i)).is_some_and(|c| matches!(c, '\n' | '\u{2028}'))) + .collect() +} + +/// Each fixed-width text with the line breaks its file saved (one-line texts too) wraps there once imported, unless a +/// line would overflow its box by more than the allowed slack in the fonts at hand (then it wraps +/// as it would without them). Prints the counts (`--nocapture`). +#[test] +fn local_samples_wrap_at_their_saved_line_breaks() { + let Some(dir) = std::env::var_os("FIGCRAFT_FIG_SAMPLES") else { return }; + let Ok(dir) = std::fs::read_dir(dir) else { return }; + figcraft_text::scan_system_fonts(); + let (mut matched, mut fell_back, mut differed_before, mut wrapped) = (0, 0, 0, 0); + let mut problems = Vec::new(); + for p in dir.flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "fig")) { + let Ok((doc, _)) = figcraft_format::fig::import(&std::fs::read(&p).unwrap(), "sample") else { continue }; + for n in doc.nodes() { + let NodeKind::Text(t) = &n.kind else { continue }; + let Some(saved) = t.saved_breaks.as_deref() else { continue }; + if t.auto_resize == TextAutoResize::WidthAndHeight { + continue; + } + wrapped += usize::from(!saved.starts.is_empty()); + let plain = figcraft_doc::TextData { saved_breaks: None, ..t.clone() }; + let (own, hinted) = (soft_starts(&plain, n.width), soft_starts(t, n.width)); + differed_before += usize::from(own != saved.starts); + if t.saved_breaks_for(n.width).is_none() { + problems.push(format!("{}: {:?}: saved breaks no longer apply after import", p.display(), t.characters)); + } else if hinted == saved.starts { + matched += 1; + } else if hinted == own { + fell_back += 1; + } else { + problems.push(format!("{}: {:?}: {hinted:?} != saved {:?}", p.display(), t.characters, saved.starts)); + } + } + } + eprintln!( + "saved line breaks ({wrapped} of the texts wrap): {matched} matched, {fell_back} fell back, {} other; {differed_before} wrapped differently without them", + problems.len() + ); + assert!(problems.is_empty(), "{problems:#?}"); +} + /// Each text box that sizes itself comes out at the size stored in the file, to the pixel. #[test] fn local_samples_keep_their_text_box_sizes() { diff --git a/crates/format/src/fig.rs b/crates/format/src/fig.rs index 5b0bd77a..27d7e684 100644 --- a/crates/format/src/fig.rs +++ b/crates/format/src/fig.rs @@ -44,6 +44,8 @@ mod text_path_tests; mod text_skip_ink_tests; #[cfg(test)] mod text_style_tests; +#[cfg(test)] +mod text_wrap_tests; use std::collections::BTreeMap; use std::io::Read; diff --git a/crates/format/src/fig/map/component.rs b/crates/format/src/fig/map/component.rs index 227c18aa..38a6a2e8 100644 --- a/crates/format/src/fig/map/component.rs +++ b/crates/format/src/fig/map/component.rs @@ -245,6 +245,7 @@ impl<'a> Mapper<'a> { if own_text(v) != Some(t) { o.remove("characterStyleOverrides"); o.remove("styleOverrideTable"); + o.remove("_figLineStarts"); if let Some(tv) = prop_text_data(val) { let size = o.get("style").and_then(|s| s.get("fontSize")).and_then(Value::as_f64); char_styles(o, tv, size, &mut |w| self.warn(w)); diff --git a/crates/format/src/fig/map/text.rs b/crates/format/src/fig/map/text.rs index 6227c4d0..b1211cfd 100644 --- a/crates/format/src/fig/map/text.rs +++ b/crates/format/src/fig/map/text.rs @@ -36,6 +36,43 @@ fn leading_trim<'a>(v: &View<'a>) -> Option<&'a str> { }) } +/// The chars that start a wrapped line in the layout the file saved with the text +/// (`derivedTextData`'s `baselines`, each line's `firstCharacter` in UTF-16 units), when that layout +/// is for these characters; lines after a hard break are left out (empty: it wraps nowhere). None +/// when the saved layout is missing, for other text or inconsistent. +fn saved_line_starts(v: &View, chars: &str) -> Option> { + // Derived data from below the layer that set the characters was laid out for other text. + let top = |k: &str| v.layers.iter().rposition(|l| l.get(k).is_some()); + if top("derivedTextData")? < top("textData")? { + return None; + } + let baselines = v.get("derivedTextData")?.get("baselines")?.list(); + // UTF-16 offset of each char, and of the end. + let mut offsets: Vec = Vec::with_capacity(chars.len() + 1); + let mut unit = 0; + for c in chars.chars() { + offsets.push(unit); + unit += c.len_utf16(); + } + offsets.push(unit); + let list: Vec = chars.chars().collect(); + let mut starts = Vec::new(); + let mut prev = None; + for b in baselines { + let u = num(b, "firstCharacter").filter(|u| *u >= 0.0 && u.fract() == 0.0 && *u < unit as f64)? as usize; + let c = offsets.binary_search(&u).ok()?; + // Lines start at the first char, then further on each time. + if !prev.map_or(c == 0, |p| c > p) { + return None; + } + prev = Some(c); + if c > 0 && !list.get(c - 1).is_some_and(|ch| matches!(ch, '\n' | '\u{2028}')) { + starts.push(c); + } + } + prev.map(|_| starts) +} + impl<'a> Mapper<'a> { pub(super) fn text(&mut self, o: &mut Obj, v: &View<'a>) { let style_node = style_link(v, "styleIdForText", "inheritTextStyleID").and_then(|s| self.nodes.get(&s).copied()); @@ -44,6 +81,10 @@ impl<'a> Mapper<'a> { let td = v.get("textData"); let chars = td.and_then(|t| t.get("characters")).and_then(Kv::str).unwrap_or(""); o.insert("characters".into(), json!(chars)); + // Where the file's own layout wrapped the lines (not a REST field). + if let Some(starts) = saved_line_starts(v, chars) { + o.insert("_figLineStarts".into(), json!(starts)); + } let mut style = text_style(&sv, None); // The style's typography beats the layer's own copy of it (the copy is stale when the style // changed). The file cannot tell a stale copy of these four values from a deliberate local diff --git a/crates/format/src/fig/text_wrap_tests.rs b/crates/format/src/fig/text_wrap_tests.rs new file mode 100644 index 00000000..f2e2b2b3 --- /dev/null +++ b/crates/format/src/fig/text_wrap_tests.rs @@ -0,0 +1,102 @@ +//! The line breaks a `.fig` file saved with its text (`derivedTextData`) come across as the text's +//! saved breaks, on messages built in code. + +use figcraft_doc::{Document, TextData}; + +use super::kiwi::Kv; + +type Fields = Vec<(&'static str, Kv<'static>)>; + +fn object(fields: Fields) -> Kv<'static> { + Kv::Object(fields) +} + +fn guid(id: u64) -> Kv<'static> { + object(vec![("sessionID", Kv::Uint(9)), ("localID", Kv::Uint(id))]) +} + +fn node(id: u64, ty: &'static str, name: &str, parent: u64, mut more: Fields) -> Kv<'static> { + let mut fields = vec![ + ("guid", guid(id)), + ("type", Kv::Enum(ty)), + ("name", Kv::Str(name.into())), + ("parentIndex", object(vec![("guid", guid(parent)), ("position", Kv::Str("a".into()))])), + ]; + fields.append(&mut more); + object(fields) +} + +/// The saved layout: one baseline per line, starting at these UTF-16 offsets. +fn derived(firsts: &[u64]) -> Kv<'static> { + let lines = firsts.iter().map(|f| object(vec![("firstCharacter", Kv::Uint(*f))])).collect(); + object(vec![("baselines", Kv::Array(lines))]) +} + +/// A 100 × 40 text layer named `name` with `chars`, sizing `resize` and the saved layout `firsts`. +fn label(id: u64, name: &str, parent: u64, chars: &str, resize: &'static str, firsts: &[u64]) -> Kv<'static> { + node( + id, + "TEXT", + name, + parent, + vec![ + ("size", object(vec![("x", Kv::Float(100.0)), ("y", Kv::Float(40.0))])), + ("fontSize", Kv::Float(14.0)), + ("textAutoResize", Kv::Enum(resize)), + ("textData", object(vec![("characters", Kv::Str(chars.into()))])), + ("derivedTextData", derived(firsts)), + ], + ) +} + +fn import(nodes: Vec>) -> Document { + let page = vec![ + object(vec![("guid", guid(1)), ("type", Kv::Enum("DOCUMENT")), ("name", Kv::Str("Document".into()))]), + node(2, "CANVAS", "Page", 1, vec![]), + ]; + let message = object(vec![("nodeChanges", Kv::Array(page.into_iter().chain(nodes).collect()))]); + let value = super::map::to_rest(&message, "Synthetic wrap", crate::rest::MAX_NODES).0; + crate::rest::import_fig(&value, &|_| None).unwrap().0 +} + +fn texts<'d>(doc: &'d Document, name: &str) -> Vec<&'d TextData> { + doc.nodes().filter(|n| n.name == name).filter_map(|n| n.kind.text()).collect() +} + +#[test] +fn saved_line_starts_come_across_as_chars_without_hard_breaks() { + // UTF-16: the emoji takes two units, so "c" is unit 6 but char 5; "e" follows a hard break. + let doc = import(vec![ + label(10, "Wrapped", 2, "😀 ab cd\nef", "HEIGHT", &[0, 6, 9]), + label(11, "Unwrapped", 2, "Followers", "NONE", &[0]), + label(12, "Auto width", 2, "ab cd", "WIDTH_AND_HEIGHT", &[0, 3]), + label(13, "Inside a pair", 2, "😀 ab", "HEIGHT", &[0, 1]), + label(14, "Out of order", 2, "ab cd ef", "HEIGHT", &[0, 6, 3]), + label(15, "Not from the start", 2, "ab cd", "HEIGHT", &[3]), + ]); + let wrapped = texts(&doc, "Wrapped")[0]; + assert_eq!(wrapped.saved_breaks_for(100.0), Some(&[5][..])); + assert_eq!(texts(&doc, "Unwrapped")[0].saved_breaks_for(100.0), Some(&[][..]), "one line: no wrapped start"); + for name in ["Auto width", "Inside a pair", "Out of order", "Not from the start"] { + assert_eq!(texts(&doc, name)[0].saved_breaks, None, "{name}"); + } +} + +#[test] +fn a_saved_layout_for_other_text_is_left_out() { + let row = object(vec![ + ("guidPath", object(vec![("guids", Kv::Array(vec![guid(21)]))])), + ("textData", object(vec![("characters", Kv::Str("Something much longer".into()))])), + ]); + let doc = import(vec![ + node(20, "SYMBOL", "Main", 2, vec![]), + label(21, "Label", 20, "Hi there", "HEIGHT", &[0, 3]), + node(30, "INSTANCE", "Inst", 2, vec![("symbolData", object(vec![("symbolID", guid(20)), ("symbolOverrides", Kv::Array(vec![row]))]))]), + ]); + let labels = texts(&doc, "Label"); + let main = labels.iter().find(|t| t.characters == "Hi there").unwrap(); + assert_eq!(main.saved_breaks_for(100.0), Some(&[3][..])); + // The instance's text comes from its override, the saved layout only from the main. + let inst = labels.iter().find(|t| t.characters == "Something much longer").unwrap(); + assert_eq!(inst.saved_breaks, None); +} diff --git a/crates/format/src/rest/text.rs b/crates/format/src/rest/text.rs index 18e5015e..83055dc1 100644 --- a/crates/format/src/rest/text.rs +++ b/crates/format/src/rest/text.rs @@ -46,6 +46,17 @@ pub(super) fn text_data(cx: &mut Ctx, v: &Value) -> TextData { _ => cx.warn("invalid internal .fig text path data was ignored"), } } + // The line breaks the .fig file saved, for the box width the layer imports with. + let width = v.get("size").and_then(super::props::vec2).map(|(w, _)| w.clamp(0.0, super::props::MAX_SIZE)); + if cx.fig_sources.is_some() + && td.path.is_none() + && td.auto_resize != TextAutoResize::WidthAndHeight + && let (Some(starts), Some(width)) = (v.get("_figLineStarts").and_then(Value::as_array), width) + { + let n = td.char_count(); + let starts = starts.iter().filter_map(Value::as_u64).filter_map(|s| usize::try_from(s).ok()).filter(|s| *s < n).collect(); + td.save_breaks(starts, width); + } td } diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index bb6cee7f..cf0d5512 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -470,6 +470,30 @@ fn safe_break_index(items: &[Item], chars: &[char], cluster_start: &[bool], line (line_start + 1..brk).rev().find(|&k| safe(k)).or(carry).unwrap_or(brk) } +/// How much wider than its box a saved line may come out in the fonts at hand before the saved +/// breaks are given up: past that the font isn't the one the file was laid out with (a stand-in), +/// and the text wraps by its own measure. +const SAVED_LINE_SLACK: f64 = 1.03; + +/// Saved line starts being followed ([`TextData::saved_breaks_for`]). +struct SavedStarts<'a> { + starts: &'a [usize], + next: usize, +} + +impl SavedStarts<'_> { + /// Whether a line starting at char `from` breaks before char `at`: a saved start lies in + /// `(from, at]` (one inside a cluster or a run of spaces breaks at the next glyph after it). + fn due(&mut self, from: usize, at: usize) -> bool { + while self.starts.get(self.next).is_some_and(|s| *s <= from) { + self.next += 1; + } + self.starts.get(self.next).is_some_and(|s| *s <= at) + } +} + +/// The lines (item range, paragraph end, paragraph), wrapped by measure or at `saved` line starts; +/// None when a saved line would overflow its box by more than [`SAVED_LINE_SLACK`]. fn break_lines( items: &[Item], chars: &[char], @@ -479,7 +503,8 @@ fn break_lines( rtl_para_at: &impl Fn(usize) -> bool, hang_start: bool, hang_end: bool, -) -> Vec<(usize, usize, bool, usize)> { + mut saved: Option, +) -> Option> { let mut lines: Vec<(usize, usize, bool, usize)> = Vec::new(); // item range, paragraph end, paragraph let mut line_start = 0; let mut i = 0; @@ -517,18 +542,28 @@ fn break_lines( let hung = items.get(line_start).filter(|f| hang_start && ltr && opens(f.ch)).map_or(0.0, |f| f.adv) + if hang_end && ltr && closes(it.ch) { w } else { 0.0 }; if let Some(maxw) = wrap - && x + w - it.spacing > maxw - line_indent + hung + 1e-6 && i > line_start && !it.is_space() { - let brk = safe_break_index(items, chars, cluster_start, line_start, last_break.filter(|b| *b >= line_start).map(|b| b + 1).unwrap_or(i)); - lines.push((line_start, brk, false, para)); - line_start = brk; - i = brk; - x = 0.0; - last_break = None; - para_first_line = false; - continue; + let (width, room) = (x + w - it.spacing, maxw - line_indent + hung); + let line_char = items.get(line_start).map_or(0, |f| f.char_start); + let brk = match saved.as_mut().map(|s| s.due(line_char, it.char_start)) { + Some(true) => Some(i), + Some(false) if width > room * SAVED_LINE_SLACK + 1.0 => return None, + Some(false) => None, + None => (width > room + 1e-6).then(|| { + safe_break_index(items, chars, cluster_start, line_start, last_break.filter(|b| *b >= line_start).map(|b| b + 1).unwrap_or(i)) + }), + }; + if let Some(brk) = brk { + lines.push((line_start, brk, false, para)); + line_start = brk; + i = brk; + x = 0.0; + last_break = None; + para_first_line = false; + continue; + } } x += w; if items.get(i + 1).is_some_and(|next| crate::breaks::break_between(it.ch, next.ch)) { @@ -540,7 +575,7 @@ fn break_lines( lines.push((line_start, items.len(), true, para)); } - lines + Some(lines) } #[derive(Clone, Copy)] @@ -788,7 +823,10 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) TextAutoResize::WidthAndHeight => None, _ => box_width.filter(|w| w.is_finite() && *w > 0.0), }; - let lines = break_lines(&items, &chars, &cluster_start, wrap, &line_indent_of, &rtl_para_at, hang_start, hang_end); + let breaks = |saved| break_lines(&items, &chars, &cluster_start, wrap, &line_indent_of, &rtl_para_at, hang_start, hang_end, saved); + // Where the file saved its line breaks, they win while the text and box are as saved. + let saved = wrap.and_then(|w| text.saved_breaks_for(w)).map(|starts| SavedStarts { starts, next: 0 }); + let lines = saved.and_then(|s| breaks(Some(s))).or_else(|| breaks(None)).unwrap_or_default(); // Line metrics and positions. let default_style = styles.first().cloned().unwrap_or_default(); let default_face = faces.first().cloned().flatten(); diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 8da0b2d8..2e99b64e 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -161,6 +161,8 @@ mod onpath_tests; #[cfg(test)] mod rtl_list_tests; #[cfg(test)] +mod saved_breaks_tests; +#[cfg(test)] mod tail_tests; #[cfg(test)] mod tests; diff --git a/crates/text/src/saved_breaks_tests.rs b/crates/text/src/saved_breaks_tests.rs new file mode 100644 index 00000000..faf14b70 --- /dev/null +++ b/crates/text/src/saved_breaks_tests.rs @@ -0,0 +1,95 @@ +//! Line breaks a file saved with its text (an imported `.fig`'s layout): followed while the text +//! and its box are as saved, so a font whose advances differ slightly from the file's doesn't move +//! a word to the next line or split one, and dropped by any edit that could change the wrap. + +use std::sync::Arc; + +use figcraft_doc::{TextAutoResize, TextData}; + +use super::{db, layout, layout_cached, scan_system_fonts}; + +/// A 20 px auto-height text in the default face; None (skip) on a machine without fonts. +fn text(s: &str) -> Option { + scan_system_fonts(); + let f = db().write().unwrap().default_face()?; + let mut t = TextData::new(s); + (t.style.font_family, t.style.font_style) = (f.family.clone(), f.style.clone()); + t.style.font_size = 20.0; + t.auto_resize = TextAutoResize::Height; + Some(t) +} + +/// How wide `s` sets on one line. +fn width(t: &TextData, s: &str) -> f64 { + layout(&TextData { characters: s.into(), auto_resize: TextAutoResize::WidthAndHeight, ..t.clone() }, None, None).width +} + +fn lines(t: &TextData, w: f64) -> Vec { + let chars: Vec = t.characters.chars().collect(); + layout(t, Some(w), None).lines.iter().map(|l| chars.get(l.char_start..l.char_end).unwrap_or(&[]).iter().collect()).collect() +} + +#[test] +fn saved_breaks_keep_a_word_our_metrics_would_push_to_the_next_line() { + let Some(mut t) = text("Lorem ipsum a") else { return }; + // A box 1% narrower than "Lorem ipsum": the file's font fitted it, ours doesn't. + let w = width(&t, "Lorem ipsum") * 0.99; + assert_eq!(lines(&t, w), ["Lorem ", "ipsum a"], "unhinted, the word wraps early"); + t.save_breaks(vec![12], w); + assert_eq!(lines(&t, w), ["Lorem ipsum ", "a"]); +} + +#[test] +fn saved_breaks_keep_a_word_whole_in_a_narrow_box() { + let Some(mut t) = text("Followers") else { return }; + let w = width(&t, "Followers") * 0.99; + assert_eq!(lines(&t, w).len(), 2, "unhinted, the word splits"); + t.save_breaks(vec![], w); + assert_eq!(lines(&t, w), ["Followers"]); +} + +#[test] +fn saved_breaks_stop_applying_after_a_layout_edit_or_resize() { + let Some(mut t) = text("Lorem ipsum a") else { return }; + let w = width(&t, "Lorem ipsum") * 0.99; + t.save_breaks(vec![12], w); + // Links and vertical settings don't move breaks: the saved ones still hold. + let mut painted = t.clone(); + painted.style.link = Some("https://example.com".into()); + painted.paragraph_spacing = 8.0; + assert_eq!(lines(&painted, w), ["Lorem ipsum ", "a"]); + let mut typed = t.clone(); + typed.characters = "Lorem ipsum ab".into(); + let mut sized = t.clone(); + sized.style.font_size = 21.0; + for (edited, why) in [(&typed, "chars"), (&sized, "size")] { + assert_eq!(edited.saved_breaks_for(w), None, "{why}"); + } + assert_eq!(lines(&typed, w), ["Lorem ", "ipsum ab"]); + assert_eq!(t.saved_breaks_for(w + 1.0), None, "another box width"); + // Hinted and unhinted texts are told apart by the layout cache. + let plain = TextData { saved_breaks: None, ..t.clone() }; + assert!(!Arc::ptr_eq(&layout_cached(&t, Some(w), None), &layout_cached(&plain, Some(w), None))); +} + +#[test] +fn saved_breaks_that_would_overflow_the_box_are_dropped() { + let Some(mut t) = text("Lorem ipsum a") else { return }; + // 10% narrower: a font that much wider isn't the one the file was laid out with. + let w = width(&t, "Lorem ipsum") * 0.9; + let own = lines(&t, w); + t.save_breaks(vec![12], w); + assert_eq!(lines(&t, w), own); +} + +#[test] +fn saved_breaks_leave_hard_breaks_and_bad_starts_alone() { + let Some(mut t) = text("Lorem ipsum a\nb") else { return }; + let w = width(&t, "Lorem ipsum") * 0.99; + t.save_breaks(vec![12], w); + assert_eq!(lines(&t, w), ["Lorem ipsum ", "a\n", "b"]); + for junk in [vec![0], vec![12, 12], vec![15], vec![40]] { + t.save_breaks(junk.clone(), w); + assert_eq!(t.saved_breaks_for(w), None, "{junk:?}"); + } +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 6a5a9d2e..a46c3924 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-10-29 · **Change:** minor (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-10-29 · **Change:** minor (`.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 @@ -17,7 +17,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| -| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks with region-specific fill paints (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes and shapes with text keep their generated backgrounds, styled text, text paints, saved child bounds and sticky author lines as editable frame children, including saved vector networks without geometry paths, outer fill/stroke outlines when generated geometry is damaged, and outer body text when its generated child is unusable even if an author survives (#1341, #1397); connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, live style and variable links for vector region fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed), `crates/engine/src/shapebuilder_region_paints_tests.rs`, and by reading back what the export writes; the FigJam report sample from #1341 checked locally only; no representative real-world corpus yet | read tail 5–10, write: checks against real files 5–15 | +| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks with region-specific fill paints (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes and shapes with text keep their generated backgrounds, styled text, text paints, saved child bounds and sticky author lines as editable frame children, including saved vector networks without geometry paths, outer fill/stroke outlines when generated geometry is damaged, and outer body text when its generated child is unusable even if an author survives (#1341, #1397); connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message; since 2026-10-29, the line breaks the file saved with a text, followed until an edit or a resize could move one, or where a line would come out more than 3% wider than its box in the fonts at hand (#1236). Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, live style and variable links for vector region fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed), `crates/engine/src/shapebuilder_region_paints_tests.rs`, and by reading back what the export writes; the FigJam report sample from #1341 checked locally only; no representative real-world corpus yet | read tail 5–10, write: checks against real files 5–15 | | `.penpot` (Penpot 2.x ZIP export) | — | **yes, offline** (open, import, drop, CLI, MCP) | **yes, offline** (export, CLI convert) | Editable layers, supported flex/grid layout, components, tokens and prototypes using the existing model. Unsupported layouts retain saved positions and bounded metadata; toggle-overlay and embedded font binaries are reported. Font references remain editable; no downloads. Synthetic regression coverage; live Penpot and cross-platform QA remain open. See [Penpot reference](penpot.md). | further live Penpot and cross-platform QA | | `.sketch` (Sketch 48 and newer) | import (file browser ▸ Import or drop, desktop app too; converted to a Figma file, symbols as components; per the help center) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP; Open With from Finder, Explorer and Linux file managers once installed; recent files show its `previews/preview.png`) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, stretching filling every item across, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides; from Sketch 64, before which a group's fill draws nothing), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); operands' own borders ignored as Sketch does; open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (their image under their style's fills; colour adjustments as image filters with Sketch's tone curve, contrast and then brightness added), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients with Sketch's geometry, gradients mixed in OKLab or OKLCH as sRGB stops along the same colours, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer, motion and background blurs (Sketch's radius is the standard deviation), opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does, and its opacity fades it and what it clips together; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, also on shapes' image fills, layer and text styles, nested paths), clipping only where the master clips (`clippingBehavior` 1), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only when the master includes them in both instances and exports, as on Sketch's canvas and renders; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids (hidden, as Sketch shows them only in its layout view). Instances of a stack laid out at their size and texts an override changed measured again on open, an auto-width one growing from its aligned edge. Warnings: noise fills, OKLab and OKLCH gradients, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 68 and 83 (70,000 layers open in ~3 s, debug build; its edition saved by Sketch 47 is refused, as it should be); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 69 1.1% on average and at most 4.4% (text in IBM Plex, which the file embeds and FigCraft doesn't use yet), a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 0.9% on average (at most 1.9%); the rules for master backgrounds, symbol clipping, mask opacity, bitmap fills, image overrides, stretching stacks, blur radii, colour adjustments, gradient interpolation and text growth checked against Sketch's renders of files built for each case (black-box, synthetic) | 5–10 (Smart Layout as auto layout, motion and zoom blurs, several borders of different widths; fonts embedded in the file once `.figcraft` documents can carry fonts: until then they would only last the session). Known limitations: a plain group (not a frame) that fills a stack keeps its layers' size where Sketch stretches them; a font Sketch can't find draws in the installed family's own weight (Sketch falls back to a regular face); text in the system SF Pro comes out a few percent narrower than Sketch's; macOS's reserved system fonts (PingFang) aren't found; brightness past about +0.4 is approximated (image filters can't add); OKLab gradients become sRGB stops (FigCraft gradients mix in sRGB) | | `.jam` (whiteboards), `.deck` (Slides) | native to other Figma products | no | no | direct `.jam` opening is not supported (#1342); its `canvas.fig` can be read best effort after renaming a copy to `.fig`, with generated sticky/shape backgrounds and text kept (#1341), as ordinary design layers rather than native board objects (see [boards-parity](boards-parity.md)); writing `.jam` is not supported; `.deck` out of scope | — | diff --git a/docs/gaps.md b/docs/gaps.md index 7180a50b..cac8392f 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); Arabic draft catalog; gap 7: the browser refines a zoom's preview once the zoom has paused; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (`.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); Arabic draft catalog; gap 7: the browser refines a zoom's preview once the zoom has paused; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -89,11 +89,24 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe - **Landed (2026-10-26):** `.fig` import reads vector-region fill stacks; vector editing and path operations retain them with their source paint coordinates. `.fig` export still warns and drops region fills. Evidence: `crates/format/src/fig/tail_tests.rs`, `crates/engine/src/shapebuilder_region_paints_tests.rs`. +- **Fixed (2026-10-29, #1236):** imported text keeps the line breaks the file saved with it + (`derivedTextData`), so a font whose advances differ slightly from the file's (another build + of the family, a stand-in) no longer wraps a word early or splits one in a narrow box. The + breaks hold only while the box width and everything that moves a break (characters, fonts, + sizes, spacing, case, axes, features, lists, paragraph settings) are as saved, and are given up + where a line would come out more than 3% (+1 px) wider than its box. The line breaker itself + was checked and left as is: trailing spaces and the letter spacing after a line's last glyph + already stay out of the fit test, box widths aren't rounded and the fit tolerance is 1e-6 px, + so the differences come from the fonts' advances. Evidence: + `crates/text/src/saved_breaks_tests.rs`, `crates/format/src/fig/text_wrap_tests.rs`; not + checked on real files. - **Missing:** elbowed or curved connectors without a saved path (drawn straight), labels of connectors without a saved snapshot, table cells beyond their own layers, live region style/variable links, variables bound to fields the editor can't bind and video fills aren't read; text lays out again with - local fonts. Beyond the local FigJam regression check for #1341, there is no real-world corpus (gap 3): the 2026-10-17 + local fonts (an unedited text keeps the file's line breaks, but glyph positions, line widths + and heights come from the fonts at hand, and an edited one wraps by them), and the saved breaks + were never compared with real files. Beyond the local FigJam regression check for #1341, there is no real-world corpus (gap 3): the 2026-10-17 changes were tested on files built in code, and paste from another design app on clipboard HTML made from our own export, never on a real clipboard. The export was checked in other software on our example documents and small test files only. diff --git a/docs/roadmap.md b/docs/roadmap.md index b59384ce..4cd37b0c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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-10-28 · **Change:** minor (`.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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M6, #1236 (gap 1): imported `.fig` text keeps the line breaks the file saved with it (`derivedTextData`), so a font whose advances differ slightly from the file's (another build of the family, a stand-in) no longer wraps a word early or splits one in a narrow box. The breaks hold only while the box width and everything that moves a break are as saved (characters, fonts, sizes, spacing, case, axes, features, lists, paragraph settings; paints, links and vertical settings don't count), and are given up where a line would come out more than 3% (+1 px) wider than its box; they are kept in `.figcraft`. The line breaker was checked and is unchanged: trailing spaces and the letter spacing after a line's last glyph already stay out of the fit test, box widths aren't rounded and the tolerance is 1e-6 px, so the differences come from the fonts. Synthetic tests in `saved_breaks_tests` and `fig::text_wrap_tests`; not checked on real files. Numbers unchanged | | 2026-10-29 | minor | #754, #1025: the Missing fonts window offers Download for a family the suite's font store has (Google Fonts, Noto: craft-libs' `craft-font-store`, its pinned indexes compiled in). The first Download asks the user to allow downloading fonts (off until then; the setting is the Craft apps' shared one); the files download on a thread of their own, each checked against the index's size and sha256 before it is installed into the shared font folder, which the font scan now reads; the row shows progress or why it failed, and once installed the file's text fits the real font and the family leaves the list. `font.download {family}` and `font.downloads` are commands, but no command turns downloading on; agents install only while the user has it on. Desktop only. No cancel, no turning it off from FigCraft, no substitute picker. Tests: `fonts::download::tests`, `missing_fonts_tests`. Numbers unchanged | | 2026-10-29 | minor | Web wasm size (#1472): the release wasm is 23,915,213 bytes (22.8 MiB), down from 25,982,540 (24.8 MiB), 1.2 MiB under the gate. Catalog rows hold their plural forms behind a reference (two words, not eight) and the browser build compiles with wasm SIMD, so the glyph rasterizer's scalar fallback is left out; the 24 MiB gate in `packaging/web/package.sh` is restored. The browser now needs WebAssembly SIMD (Safari 16.4 or later). Numbers unchanged. | | 2026-10-29 | minor | Shadows of images with transparent pixels (M3, #1289): a drop shadow is cast from the picture's alpha and hidden only behind its pixels, not its whole box (a transparent PNG's shadow was cut away inside its bounding box), unless shown behind transparent areas; its inner shadow shades the inside of its alpha, not the box's edge; the same for Fit images, crops smaller than the box and pattern fills. Opaque pictures that fill their box keep their outline (spread still offsets it exactly); each image is scanned for transparency once. Canvas, PNG, SVG (knocked out by the layer's alpha; an inner shadow embeds a render) and PDF (`image_shadow_tests` in render and svg). Numbers unchanged | From f91bc16ad963928a59b21e83dfc3480916cb34d6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:07:58 +0000 Subject: [PATCH 09/63] ci(deps): bump storytold/craft-common-actions/.github/workflows/windows-arm64.yml Bumps the actions group with 1 update: [storytold/craft-common-actions/.github/workflows/windows-arm64.yml](https://github.com/storytold/craft-common-actions). Updates `storytold/craft-common-actions/.github/workflows/windows-arm64.yml` from 8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f to fa5ef18780cf5e7d5d3b8de068de56cffc83cac5 - [Commits](https://github.com/storytold/craft-common-actions/compare/8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f...fa5ef18780cf5e7d5d3b8de068de56cffc83cac5) --- updated-dependencies: - dependency-name: storytold/craft-common-actions/.github/workflows/windows-arm64.yml dependency-version: fa5ef18780cf5e7d5d3b8de068de56cffc83cac5 dependency-type: direct:production dependency-group: actions ... Signed-off-by: dependabot[bot] --- .github/workflows/windows-arm64.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 267479eb..e4cd6a93 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -27,7 +27,7 @@ permissions: jobs: windows-arm64: # Immutable supporting workflow revision until the new install contract lands upstream. - uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@fa5ef18780cf5e7d5d3b8de068de56cffc83cac5 with: app-name: figcraft app-dir: FigCraft From 9dab7669cb673e6773553786e98bc90cf3ad5e18 Mon Sep 17 00:00:00 2001 From: Carbocode Date: Thu, 29 Oct 2026 11:20:01 +0100 Subject: [PATCH 10/63] M1: add a draggable corner smoothing icon --- ROADMAP.md | 5 ++- contributors/people.toml | 3 ++ crates/ui-egui/src/corner_individual_tests.rs | 37 +++++++++++++++++++ crates/ui-egui/src/icons.rs | 2 + crates/ui-egui/src/icons/draw.rs | 12 ++++++ crates/ui-egui/src/panels/right/paints.rs | 2 +- docs/ui-parity.md | 5 ++- 7 files changed, 62 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3889a2b3..8de2d7fa 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-29 · **Change:** minor (`.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-10-29 · **Change:** minor (Corner smoothing: a visible icon supports dragging the percentage with the shared field gesture; `.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,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-29** · M1: Corner smoothing has an original, code-drawn icon with the same 24 pt drag target as the other number fields. Dragging it changes the percentage live, Shift and Alt use the shared rates, the value stays within 0–100%, and the gesture is one undo step. The field is named for screen readers. Regression: `corner_individual_tests`. Numbers unchanged. + - **2026-10-28** · M3, #922: an instance scaled with the Scale tool (or `transform.scale`) keeps its layers' scaled size and points when its main is rebuilt. The scaled layers' new size and place were read as what constraints give (an ancestor resized in the same edit), so no override was kept, and adding a layer to the main put a 14 px icon's 24 px triangle back. The Scale tool now records them as `size` and `position` overrides on layers inside instances; strokes already kept theirs. The report's own sequence (clearing effects on the component holding the icon) no longer reproduces on main. Regressions for a scaled instance, one scaled with the tool (with undo) and a scaled icon nested in a component and its instances. Numbers unchanged. - **2026-10-28** · M6, #1059: `.fig` import copies each image carried inside the message once per blob, shared by every hash that names it, instead of once per hash: a file pointing many hashes at one large blob no longer multiplies it in memory (100 hashes on a 250 MB blob were 25 GB). Regression test. Numbers unchanged. - **2026-10-28** · M1, #1476: page ruler guides survive save and reopen. A page kept its guides in the page kind while the layer's own `guides` field (frame guides, #70) took that key when a file was read, so a reopened page's guides vanished, and saving it wrote `guides` twice, which then refused to open. Every layer now keeps its guides in the one field, and a page with two `guides` lists opens with both. Regressions in the doc and format crates. Numbers unchanged. @@ -355,6 +357,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M1: Corner smoothing gets an original code-drawn icon, the shared 24 pt scrub target and an accessible name. Live percentage changes, Shift/Alt rates, 0–100% clamping and one-step undo/redo have headless pointer regression coverage (`corner_individual_tests`). Numbers unchanged | | 2026-10-29 | minor | M6, #1236 (gap 1): imported `.fig` text keeps the line breaks the file saved with it (`derivedTextData`), so a font whose advances differ slightly from the file's (another build of the family, a stand-in) no longer wraps a word early or splits one in a narrow box. The breaks hold only while the box width and everything that moves a break are as saved (characters, fonts, sizes, spacing, case, axes, features, lists, paragraph settings; paints, links and vertical settings don't count), and are given up where a line would come out more than 3% (+1 px) wider than its box; they are kept in `.figcraft`. The line breaker was checked and is unchanged: trailing spaces and the letter spacing after a line's last glyph already stay out of the fit test, box widths aren't rounded and the tolerance is 1e-6 px, so the differences come from the fonts. Synthetic tests in `saved_breaks_tests` and `fig::text_wrap_tests`; not checked on real files. Numbers unchanged | | 2026-10-29 | minor | #754, #1025: the Missing fonts window offers Download for a family the suite's font store has (Google Fonts, Noto: craft-libs' `craft-font-store`, its pinned indexes compiled in). The first Download asks the user to allow downloading fonts (off until then; the setting is the Craft apps' shared one); the files download on a thread of their own, each checked against the index's size and sha256 before it is installed into the shared font folder, which the font scan now reads; the row shows progress or why it failed, and once installed the file's text fits the real font and the family leaves the list. `font.download {family}` and `font.downloads` are commands, but no command turns downloading on; agents install only while the user has it on. Desktop only. No cancel, no turning it off from FigCraft, no substitute picker. Tests: `fonts::download::tests`, `missing_fonts_tests`. Numbers unchanged | | 2026-10-29 | minor | Web wasm size (#1472): the release wasm is 23,915,213 bytes (22.8 MiB), down from 25,982,540 (24.8 MiB), 1.2 MiB under the gate. Catalog rows hold their plural forms behind a reference (two words, not eight) and the browser build compiles with wasm SIMD, so the glyph rasterizer's scalar fallback is left out; the 24 MiB gate in `packaging/web/package.sh` is restored. The browser now needs WebAssembly SIMD (Safari 16.4 or later). Numbers unchanged. | diff --git a/contributors/people.toml b/contributors/people.toml index 14c2ad43..77553d44 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -95,3 +95,6 @@ sync_github_name = true [people.StrajnarFilip] display_name = "Filip Strajnar" + +[people.Carbocode] +sync_github_name = true diff --git a/crates/ui-egui/src/corner_individual_tests.rs b/crates/ui-egui/src/corner_individual_tests.rs index c87f4f33..7a68768a 100644 --- a/crates/ui-egui/src/corner_individual_tests.rs +++ b/crates/ui-egui/src/corner_individual_tests.rs @@ -70,6 +70,43 @@ fn ios_button_sets_corner_smoothing_to_60_percent() { assert_eq!(u.app.session.undo_depth().0, history + 1); } +/// The visible smoothing icon uses the same scrub gesture, cursor and modifier rates as the +/// other numeric fields; the whole drag is one undo step, including when clamped at either end. +#[test] +fn corner_smoothing_icon_scrubs_with_modifiers_and_one_undo_step() { + for (dx, modifiers, want) in + [(16.0, Modifiers::NONE, 0.66), (8.0, Modifiers::SHIFT, 1.0), (16.0, Modifiers::ALT, 0.516), (-80.0, Modifiers::NONE, 0.0)] + { + let (mut u, r) = scene(); + u.app.run("node.set", json!({"ids": [r], "props": {"cornerSmoothing": 0.5}})); + let toggle = named_button(&mut u, "Individual corners"); + click(&mut u, toggle); + let lead = u.ctx.read_response(Id::unique("smoothing").with("lead")).expect("smoothing icon").rect; + assert_eq!(lead.width(), 24.0, "the icon has the same drag target as other fields"); + let from = lead.center(); + let out = u.frame_output(vec![Event::PointerMoved(from)]); + assert_eq!(out.platform_output.cursor_icon, egui::CursorIcon::ResizeHorizontal); + let history = u.app.session.undo_depth().0; + u.frame_with(vec![Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers }], modifiers); + for step in 1..=4 { + u.frame_with(vec![Event::PointerMoved(pos2(from.x + dx * step as f32 / 4.0, from.y))], modifiers); + } + let smoothing = |u: &Ui| u.app.session.doc().unwrap().node(r).unwrap().corners.smoothing; + assert!((smoothing(&u) - want).abs() < 1e-9, "the drag updates the canvas before release"); + u.frame_with( + vec![Event::PointerButton { pos: pos2(from.x + dx, from.y), button: egui::PointerButton::Primary, pressed: false, modifiers }], + modifiers, + ); + u.frame(vec![]); + assert!((smoothing(&u) - want).abs() < 1e-9, "release keeps the scrubbed value"); + assert_eq!(u.app.session.undo_depth().0, history + 1); + u.app.session.undo().unwrap(); + assert_eq!(smoothing(&u), 0.5); + u.app.session.redo().unwrap(); + assert!((smoothing(&u) - want).abs() < 1e-9); + } +} + /// The fields sit at their corners, and typing in the bottom right one sets only that corner in a /// single undo step (#1002: the bottom row showed bottom right on the left). #[test] diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index eeacb275..a891abed 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -89,6 +89,8 @@ icons! { FlipH, FlipV, CornerRadius, + /// A smooth corner with its two tangent endpoints. + CornerSmoothing, /// Individual corners: the rounded corner each field sets. CornerTopLeft, CornerTopRight, diff --git a/crates/ui-egui/src/icons/draw.rs b/crates/ui-egui/src/icons/draw.rs index 7eedfdd1..953ef2de 100644 --- a/crates/ui-egui/src/icons/draw.rs +++ b/crates/ui-egui/src/icons/draw.rs @@ -510,6 +510,18 @@ fn draw(p: &P, icon: Icon) { p.fill(&[(3.0, 9.0), (13.0, 9.0), (13.0, 14.0)]); } CornerRadius | CornerTopLeft => p.corner(2.5, 2.5, 1.0, 1.0, 11.5, 6.0), + CornerSmoothing => { + // An original superellipse corner, with dots marking its tangent endpoints. + let points: Vec<_> = (0..=24) + .map(|i| { + let a = i as f32 / 24.0 * FRAC_PI_2; + (2.5 + 11.0 * (1.0 - a.cos().max(0.0).sqrt()), 2.5 + 11.0 * (1.0 - a.sin().max(0.0).sqrt())) + }) + .collect(); + p.path(&points, false); + p.dot(2.5, 13.5, 1.0); + p.dot(13.5, 2.5, 1.0); + } CornerTopRight => p.corner(13.5, 2.5, -1.0, 1.0, 11.5, 6.0), CornerBottomRight => p.corner(13.5, 13.5, -1.0, -1.0, 11.5, 6.0), CornerBottomLeft => p.corner(2.5, 13.5, 1.0, -1.0, 11.5, 6.0), diff --git a/crates/ui-egui/src/panels/right/paints.rs b/crates/ui-egui/src/panels/right/paints.rs index a24fca55..5f69098e 100644 --- a/crates/ui-egui/src/panels/right/paints.rs +++ b/crates/ui-egui/src/panels/right/paints.rs @@ -116,7 +116,7 @@ pub(super) fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[N let sm = common(doc, sel, |n| (n.corners.smoothing * 100.0).round()); widgets::sublabels(ui, tr("Corner smoothing"), None); row(ui, 32.0, |ui| { - let e = widgets::number_field(ui, Id::unique("smoothing"), Lead::None, sm, "%", col, 1.0); + let e = widgets::number_field(ui, Id::unique("smoothing"), Lead::Icon(Icon::CornerSmoothing, tr("Corner smoothing")), sm, "%", col, 1.0); app.field(&ids, "cornerSmoothing", e, |v| json!((v / 100.0).clamp(0.0, 1.0))); if widgets::button(ui, tr("iOS"), false, true).on_hover_text(tr("Set corner smoothing to 60%")).clicked() { app.set_props(&ids, json!({"cornerSmoothing": 0.6})); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 1503b8e5..4a1fa256 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-10-29 · **Change:** minor (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; 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; 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; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; 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) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (Corner smoothing: an original icon and the shared number-field drag gesture; 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; 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; 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; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; 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) · **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: @@ -68,7 +68,7 @@ interaction feel not yet compared. | Ctrl to suspend snapping while dragging (macOS) | done | Control on macOS (Cmd keeps working), Ctrl elsewhere; moving, resizing and drawing | | Measure (Alt hover, Cmd+Alt nested, Shift+Alt padding/gaps) | done | labels never overlap; the arrows still nudge while measuring (Shift: the big step) and the distances follow; the selection's handles and size label hide while measuring (`measure_nudge_tests`, #894) | | Pixel grid snapping, pixel preview, 2x preview | done | moves, resizes and new shapes land on whole pixels; the pixel grid shows from 400% (`view_behaviour_tests`) | -| Numeric fields: arithmetic, scrubbing by dragging the label, mixed values | done | Numeric panels use `widgets::number_field`; Typography line height, letter spacing and paragraph spacing share its leading-icon scrub logic (#373), retaining their existing unit parsing, Auto/Mixed values, variable chips and selected-character targeting (`typography_scrub_tests`). Scrubbing the label: a step a point (1 px; 1% of opacity, 1° of rotation), ten with Shift, a tenth with Alt, each stretch at the rate held while it was dragged, one undo step when let go; past the edge of the screen it goes on where the platform reports the mouse's own motion (desktop), otherwise it stops there. Arrow keys: Up/Down step a pixel field by the small nudge and Shift by the big nudge (the preferences), other fields by their own step and ten of it; they work while typing (from what is typed) and with the keyboard on the field. Typing: `+ - * /`, brackets, units (`px`, `°`, `%`, `x`, `ms`), a decimal comma; `+10`, `*2`, `/4` are relative to the value, and `50%`, `+10%`, `-10%` too in a field that isn't a percentage. Enter, Tab or a click elsewhere commits (Tab and Shift+Tab move to the next and previous control), Escape restores; a field left as it was commits nothing (no undo step, and a rounded display isn't written back). Mixed values show "Mixed": a number sets them all, a relative edit or an arrow key goes to each layer from its own value in one undo step, for X, Y, W, H, rotation, opacity, corner radius and stroke weight (elsewhere `+10` on Mixed sets 10 and `*2` does nothing) (`field_tests`). Left: scrubbing a Mixed field (nothing happens), `x2`-style suffix edits, the pointer isn't wrapped to the other side of the screen; rates not compared with Figma's | +| Numeric fields: arithmetic, scrubbing by dragging the label, mixed values | done | Numeric panels use `widgets::number_field`; Corner smoothing has an original code-drawn icon, a 24 pt drag target and an accessible name, with live changes, Shift/Alt rates, 0–100% clamping and one-step undo/redo (`corner_individual_tests`); Typography line height, letter spacing and paragraph spacing share its leading-icon scrub logic (#373), retaining their existing unit parsing, Auto/Mixed values, variable chips and selected-character targeting (`typography_scrub_tests`). Scrubbing the label: a step a point (1 px; 1% of opacity, 1° of rotation), ten with Shift, a tenth with Alt, each stretch at the rate held while it was dragged, one undo step when let go; past the edge of the screen it goes on where the platform reports the mouse's own motion (desktop), otherwise it stops there. Arrow keys: Up/Down step a pixel field by the small nudge and Shift by the big nudge (the preferences), other fields by their own step and ten of it; they work while typing (from what is typed) and with the keyboard on the field. Typing: `+ - * /`, brackets, units (`px`, `°`, `%`, `x`, `ms`), a decimal comma; `+10`, `*2`, `/4` are relative to the value, and `50%`, `+10%`, `-10%` too in a field that isn't a percentage. Enter, Tab or a click elsewhere commits (Tab and Shift+Tab move to the next and previous control), Escape restores; a field left as it was commits nothing (no undo step, and a rounded display isn't written back). Mixed values show "Mixed": a number sets them all, a relative edit or an arrow key goes to each layer from its own value in one undo step, for X, Y, W, H, rotation, opacity, corner radius and stroke weight (elsewhere `+10` on Mixed sets 10 and `*2` does nothing) (`field_tests`). Left: scrubbing a Mixed field (nothing happens), `x2`-style suffix edits, the pointer isn't wrapped to the other side of the screen; rates not compared with Figma's | | Transform box at small sizes | done | a press inside a layer smaller than its handles moves it, the nearest handle wins, handles answer just outside and are hidden under 16 px on screen; cursors follow each handle and the box's rotation | | Locked layers | done | selectable from the layers panel; no handles, not dragged with the rest of a selection | | Smart selection spacing | partial | with layers in a row, a column or an aligned grid selected, the Layout section shows horizontal and vertical spacing ("Mixed" until set; typed, scrubbed, fractional and negative) and pink handles in the gaps on the canvas drag them, one undo step, Escape cancels (`arrange.spacing`; sizes, parents and offsets within each track kept; at most 2,000 layers; flow children, locked layers and layers inside instances left out). No reorder rings for swapping layers; checked by its author on macOS and in the app on Windows (#402) | @@ -174,6 +174,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M1: Corner smoothing has an original icon and a 24 pt drag target, using the shared field gesture (live changes, Shift/Alt rates, 0–100% clamping, one undo step). The field now has an accessible name (`corner_individual_tests`). Numbers unchanged | | 2026-10-29 | minor | Crop image (#1464): Object ▸ Crop image, the right-click menu and a crop button on the image fill row enter crop mode on the canvas (`image.crop`; the image dimmed outside the layer, corner brackets and edge bars on its box; Enter, Escape or a click outside finishes). The row stays done | | 2026-10-29 | minor | Toolbar (#1210): every state of the pill (tools, vector edit, shape builder, view only) is laid out on one 32 pt cell, 8 pt in from its ends and 8 pt between its items, with a 1 pt divider and 14 pt mode-switcher marks; the Resources button is gone, as in UI3 (the left rail's Assets tab, Shift+I, Alt+2 and View ▸ Panels ▸ Assets open the Assets panel); a narrow window still scrolls the pill (#1209) (`resources_tests`, `toolbar` tests). Numbers unchanged | | 2026-10-27 | minor | The Design panel's sections sit on one grid (#858): 16 pt left and 8 pt right margins, two 88 pt columns with 8 pt gutters and a 24 pt button column, 24 pt rows, 11 pt semibold section titles over 10 pt captions, 4 pt between the two buttons at a row's end; the panel's frame no longer carries a stroke (it counts as margin), so an icon's box ends 8 pt from the window edge. Checked in the app on Windows over the control channel | From a492cca4157aa4a5ad263cd8336b99b3c7c8ae07 Mon Sep 17 00:00:00 2001 From: BFlat Date: Thu, 29 Oct 2026 17:28:55 +0700 Subject: [PATCH 11/63] M6: .fig import resolves library text styles linked by asset key and orders restated override rows by level (#1234) (#1515) Style links that name a library style by its asset key and version resolve to the file's copy of the style, as variable links already did. The linked text style beats the copies of its values below the row that links it; rows above that one still win. Override rows that restate a nested level (overrideLevel) apply at that level instead of in wire order. --- ROADMAP.md | 3 +- crates/format/src/fig/map.rs | 11 ++- crates/format/src/fig/map/component.rs | 15 ++- crates/format/src/fig/map/layer.rs | 5 +- crates/format/src/fig/map/paint.rs | 24 +++-- crates/format/src/fig/map/text.rs | 9 +- crates/format/src/fig/text_style_tests.rs | 106 ++++++++++++++++++++++ docs/file-format-parity.md | 3 +- docs/roadmap.md | 3 +- 9 files changed, 154 insertions(+), 25 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3889a2b3..d1fa8e0f 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-29 · **Change:** minor (`.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-10-29 · **Change:** minor (`.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 @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M6, #1234: `.fig` import resolves style links that name a library style by its asset key and version instead of a guid (the file's own copy of the style), as variable links already were; text in instances of library components kept a stale copy of the style's values and no link. The linked text style beats the copies of its values on the layer and on the override rows below the row that links it, and rows above that row (an outer instance's size, say) still win. Override rows that restate a nested level's overrides (`overrideLevel`) apply at that level, below the instance's own rows, instead of in wire order. Synthetic regression tests in `text_style_tests`. Numbers unchanged | | 2026-10-29 | minor | M6, #1236 (gap 1): imported `.fig` text keeps the line breaks the file saved with it (`derivedTextData`), so a font whose advances differ slightly from the file's (another build of the family, a stand-in) no longer wraps a word early or splits one in a narrow box. The breaks hold only while the box width and everything that moves a break are as saved (characters, fonts, sizes, spacing, case, axes, features, lists, paragraph settings; paints, links and vertical settings don't count), and are given up where a line would come out more than 3% (+1 px) wider than its box; they are kept in `.figcraft`. The line breaker was checked and is unchanged: trailing spaces and the letter spacing after a line's last glyph already stay out of the fit test, box widths aren't rounded and the tolerance is 1e-6 px, so the differences come from the fonts. Synthetic tests in `saved_breaks_tests` and `fig::text_wrap_tests`; not checked on real files. Numbers unchanged | | 2026-10-29 | minor | #754, #1025: the Missing fonts window offers Download for a family the suite's font store has (Google Fonts, Noto: craft-libs' `craft-font-store`, its pinned indexes compiled in). The first Download asks the user to allow downloading fonts (off until then; the setting is the Craft apps' shared one); the files download on a thread of their own, each checked against the index's size and sha256 before it is installed into the shared font folder, which the font scan now reads; the row shows progress or why it failed, and once installed the file's text fits the real font and the family leaves the list. `font.download {family}` and `font.downloads` are commands, but no command turns downloading on; agents install only while the user has it on. Desktop only. No cancel, no turning it off from FigCraft, no substitute picker. Tests: `fonts::download::tests`, `missing_fonts_tests`. Numbers unchanged | | 2026-10-29 | minor | Web wasm size (#1472): the release wasm is 23,915,213 bytes (22.8 MiB), down from 25,982,540 (24.8 MiB), 1.2 MiB under the gate. Catalog rows hold their plural forms behind a reference (two words, not eight) and the browser build compiles with wasm SIMD, so the glyph rasterizer's scalar fallback is left out; the 24 MiB gate in `packaging/web/package.sh` is restored. The browser now needs WebAssembly SIMD (Safari 16.4 or later). Numbers unchanged. | diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 4b06e2a2..d85e7a60 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -183,12 +183,15 @@ impl<'a> View<'a> { v } - /// This view with `style` just above the node itself: the style beats the node's own copy of - /// its fields (stale when the style changed since), and override rows beat both. - fn styled(&self, style: Option<&'a Kv<'a>>) -> Self { + /// This view with `style` just above the layer that links it in `key` (or `legacy`): the style + /// beats that layer's and lower layers' copies of its fields (stale when the style changed + /// since, or written for the style linked before), and override rows above the link beat it. + fn styled(&self, style: Option<&'a Kv<'a>>, key: &str, legacy: &str) -> Self { let mut v = self.clone(); if let Some(s) = style { - v.layers.insert(v.layers.len().min(1), s); + let linked_in = |k: &str| v.layers.iter().rposition(|l| l.get(k).is_some()); + let at = linked_in(key).or_else(|| linked_in(legacy)).map_or(1, |i| i + 1); + v.layers.insert(at.min(v.layers.len()), s); } v } diff --git a/crates/format/src/fig/map/component.rs b/crates/format/src/fig/map/component.rs index 38a6a2e8..581a2e4f 100644 --- a/crates/format/src/fig/map/component.rs +++ b/crates/format/src/fig/map/component.rs @@ -36,10 +36,18 @@ pub(super) struct OverrideRow<'a> { /// How many mains are being expanded around the instance that wrote the row: the rows of /// the enclosing (outer) instances win over those of the inner ones. source_depth: usize, + /// How many levels below the writing instance the row restates (`overrideLevel`; 0 for the + /// instance's own rows). + level: usize, /// Position among the rows of the instance that wrote it; later rows win. ordinal: usize, } +/// The nested level an override row restates: its `overrideLevel`, capped; 0 when it has none. +fn override_level(row: &Kv) -> usize { + row.get("overrideLevel").and_then(Kv::f64).filter(|l| l.is_finite() && *l > 0.0).map_or(0, |l| l.min(MAX_DEPTH as f64) as usize) +} + /// The layers of a main by current GUID, and the old `overrideKey` of each that has one /// (`None` when two layers claim the same key). pub(super) struct OverrideScope { @@ -351,6 +359,7 @@ impl<'a> Mapper<'a> { row: ov, entry, source_depth, + level: override_level(ov), ordinal: row_ordinal, }); } @@ -359,8 +368,10 @@ impl<'a> Mapper<'a> { overrides.entry(path).or_default().extend(rows); } for rows in overrides.values_mut() { - // Deeper source rows apply first; outer rows win, with wire order preserved per source. - rows.sort_by_key(|row| (std::cmp::Reverse(row.source_depth), row.ordinal)); + // Deeper rows apply first and outer rows win: a row restating a nested level counts at + // that level's depth, wherever it sits on the wire. Equal depths keep the outer source + // last, then wire order. + rows.sort_by_key(|row| (std::cmp::Reverse(row.source_depth + row.level), std::cmp::Reverse(row.source_depth), row.ordinal)); } let mut all_props: BTreeMap> = outer.map(|i| (*i.props).clone()).unwrap_or_default(); let mut slot_contents: BTreeMap = outer.map(|i| (*i.slot_contents).clone()).unwrap_or_default(); diff --git a/crates/format/src/fig/map/layer.rs b/crates/format/src/fig/map/layer.rs index 0da8c6fb..461513af 100644 --- a/crates/format/src/fig/map/layer.rs +++ b/crates/format/src/fig/map/layer.rs @@ -1,7 +1,7 @@ use super::component::{Entry, Inst, OverrideRow, prop_ref}; use super::connector::connector_has_saved_label; use super::layout::{layout_grid, tracks}; -use super::paint::{effect, export_setting, has_bound_variables, paint, paint_bound, stroke, style_link}; +use super::paint::{effect, export_setting, has_bound_variables, paint, paint_bound, stroke}; use super::{ Affine, BTreeMap, BTreeSet, Kv, MAX_COMMANDS, MAX_DEPTH, Mapper, Obj, Value, View, copy_num, copy_str, generated_record, guid, json, matrix, network, num, rows, size, @@ -684,7 +684,8 @@ impl<'a> Mapper<'a> { if own_fills.list().iter().any(paint_bound) { return Some(own_fills); } - let linked_style = style_link(&View::of(s), "styleIdForFill", "inheritFillStyleID") + let linked_style = self + .style_link(&View::of(s), "styleIdForFill", "inheritFillStyleID") .and_then(|id| self.nodes.get(&id).copied()) .filter(|style| style.get("styleType").and_then(Kv::str) == Some("FILL")); match linked_style { diff --git a/crates/format/src/fig/map/paint.rs b/crates/format/src/fig/map/paint.rs index e38ddf26..7b2c56f5 100644 --- a/crates/format/src/fig/map/paint.rs +++ b/crates/format/src/fig/map/paint.rs @@ -12,25 +12,29 @@ fn filter_number(value: &Kv) -> Option { number.is_finite().then_some(number) } -/// The style a layer links in `key`, or in the older `legacy` field when `key` names none. An -/// explicit NO_STYLE in `key` is a detach and wins over the legacy field. -pub(super) fn style_link(v: &View, key: &str, legacy: &str) -> Option { - match v.get(key).and_then(|r| r.get("guid")).and_then(guid) { - Some(id) => Some(id).filter(|g| g != NO_STYLE), - None => v.get(legacy).and_then(guid).filter(|g| g != NO_STYLE), - } -} - pub(super) fn color(c: &Kv) -> Value { let ch = |k: &str| num(c, k).unwrap_or(0.0); json!({"r": ch("r"), "g": ch("g"), "b": ch("b"), "a": num(c, "a").unwrap_or(1.0)}) } impl<'a> Mapper<'a> { + /// The style a layer links in `key`, by guid or by library asset (the file's copy of a library + /// style), or in the older `legacy` field when `key` names none. An explicit NO_STYLE guid in + /// `key` with no asset is a detach and wins over the legacy field. + pub(super) fn style_link(&self, v: &View, key: &str, legacy: &str) -> Option { + let fallback = || v.get(legacy).and_then(guid).filter(|g| g != NO_STYLE); + let Some(link) = v.get(key) else { return fallback() }; + match (self.resource_id(link), link.get("guid").and_then(guid)) { + (Some(id), _) => Some(id), + (None, Some(_)) => None, + (None, None) => fallback(), + } + } + /// The style node a layer links in `key` (or the older `legacy` field), recorded for the REST /// `styles` map under `slot`. pub(super) fn style(&mut self, v: &View<'a>, key: &str, legacy: &str, slot: &str, out: &mut Obj) -> Option<&'a Kv<'a>> { - let id = style_link(v, key, legacy)?; + let id = self.style_link(v, key, legacy)?; let node = *self.nodes.get(&id)?; node.get("styleType").and_then(Kv::str)?; out.insert(slot.into(), json!(id)); diff --git a/crates/format/src/fig/map/text.rs b/crates/format/src/fig/map/text.rs index b1211cfd..13bed744 100644 --- a/crates/format/src/fig/map/text.rs +++ b/crates/format/src/fig/map/text.rs @@ -1,4 +1,4 @@ -use super::paint::{paint, style_link}; +use super::paint::paint; use super::{Kv, Mapper, Obj, Value, View, copy_num, copy_str, json, network, num}; /// The value from the last override layer holding a valid one for an optional text setting; invalid instance rows fall through. @@ -75,9 +75,10 @@ fn saved_line_starts(v: &View, chars: &str) -> Option> { impl<'a> Mapper<'a> { pub(super) fn text(&mut self, o: &mut Obj, v: &View<'a>) { - let style_node = style_link(v, "styleIdForText", "inheritTextStyleID").and_then(|s| self.nodes.get(&s).copied()); - // Instance overrides beat the shared style; the style beats the layer's own copy. - let sv = v.styled(style_node.filter(|s| s.get("styleType").and_then(Kv::str) == Some("TEXT"))); + let (key, legacy) = ("styleIdForText", "inheritTextStyleID"); + let style_node = self.style_link(v, key, legacy).and_then(|s| self.nodes.get(&s).copied()); + // Overrides above the one that links the style beat it; it beats the copies below. + let sv = v.styled(style_node.filter(|s| s.get("styleType").and_then(Kv::str) == Some("TEXT")), key, legacy); let td = v.get("textData"); let chars = td.and_then(|t| t.get("characters")).and_then(Kv::str).unwrap_or(""); o.insert("characters".into(), json!(chars)); diff --git a/crates/format/src/fig/text_style_tests.rs b/crates/format/src/fig/text_style_tests.rs index b2c4ed59..0f08459e 100644 --- a/crates/format/src/fig/text_style_tests.rs +++ b/crates/format/src/fig/text_style_tests.rs @@ -182,6 +182,112 @@ fn an_instance_override_beats_the_shared_style_and_a_detach_unlinks_it() { assert_eq!(detached["style"]["fontSize"].as_f64(), Some(40.0)); } +/// A link naming a library style by its asset `key` and `version` instead of a guid. +fn asset_link(key: &str, version: &str) -> Kv<'static> { + object(vec![("assetRef", object(vec![("key", Kv::Str(key.into())), ("version", Kv::Str(version.into()))]))]) +} + +/// The file's copy of library text style "Heading" (Inter 24, asset `lib-heading` version 3). +fn library_heading() -> Kv<'static> { + node( + 10, + "TEXT", + "Heading", + Some(2), + vec![ + ("styleType", Kv::Enum("TEXT")), + ("key", Kv::Str("lib-heading".into())), + ("version", Kv::Str("3".into())), + ("fontName", font("Inter")), + ("fontSize", Kv::Float(24.0)), + ], + ) +} + +/// An override row for the layer at `path` (guids) with `fields`. +fn row(path: &[u64], mut fields: Fields) -> Kv<'static> { + let mut all = vec![("guidPath", object(vec![("guids", Kv::Array(path.iter().map(|g| guid(*g)).collect()))]))]; + all.append(&mut fields); + object(all) +} + +fn instance(id: u64, name: &str, parent: u64, main: u64, rows: Vec>) -> Kv<'static> { + node(id, "INSTANCE", name, Some(parent), vec![("symbolData", object(vec![("symbolID", guid(main)), ("symbolOverrides", Kv::Array(rows))]))]) +} + +#[test] +fn a_library_text_style_linked_by_asset_key_is_resolved() { + let layer = vec![ + ("fontName", font("Stale copy")), + ("fontSize", Kv::Float(40.0)), + ("styleIdForText", asset_link("lib-heading", "3")), + ("textData", chars("Hi")), + ]; + let doc = import(&rest(vec![library_heading(), node(11, "TEXT", "Label", Some(2), layer)])); + let label = doc.nodes().find(|n| n.name == "Label").unwrap(); + assert_eq!(doc.style(label.styles.text.as_deref().unwrap()).unwrap().name, "Heading"); + let text = label.kind.text().unwrap(); + assert_eq!((text.style.font_family.as_str(), text.style.font_size), ("Inter", 24.0), "the style beats the stale copy"); + // A version the file holds no copy of names nothing: the layer keeps its own values, unlinked. + let layer = vec![ + ("fontName", font("Roboto")), + ("fontSize", Kv::Float(40.0)), + ("styleIdForText", asset_link("lib-heading", "9")), + ("textData", chars("Hi")), + ]; + let value = rest(vec![library_heading(), node(11, "TEXT", "Label", Some(2), layer)]); + let label = find(&value["document"], "Label").unwrap(); + assert!(label["styles"].get("text").is_none(), "{}", label["styles"]); + assert_eq!(label["style"]["fontSize"].as_f64(), Some(40.0)); +} + +#[test] +fn a_library_text_style_an_instance_links_beats_the_copies_below_it_and_outer_overrides_beat_it() { + let value = rest(vec![ + library_heading(), + node(11, "TEXT", "Body", Some(2), vec![("styleType", Kv::Enum("TEXT")), ("fontName", font("Inter")), ("fontSize", Kv::Float(14.0))]), + node(20, "SYMBOL", "Main", Some(2), vec![]), + node( + 21, + "TEXT", + "Label", + Some(20), + vec![("fontName", font("Inter")), ("fontSize", Kv::Float(14.0)), ("styleIdForText", link(11)), ("textData", chars("Hi"))], + ), + // The instance relinks the label to the library style; its row keeps a stale copy (40). + instance(30, "Linked", 2, 20, vec![row(&[21], vec![("styleIdForText", asset_link("lib-heading", "3")), ("fontSize", Kv::Float(40.0))])]), + node(40, "SYMBOL", "Card", Some(2), vec![]), + instance(41, "Nested", 40, 20, vec![row(&[21], vec![("styleIdForText", asset_link("lib-heading", "3")), ("fontSize", Kv::Float(40.0))])]), + // An instance of the card sets the size on top of the nested instance's style. + instance(50, "Outer", 2, 40, vec![row(&[41, 21], vec![("fontSize", Kv::Float(30.0))])]), + ]); + let linked = find(find(&value["document"], "Linked").unwrap(), "Label").unwrap(); + assert_eq!(linked["styles"]["text"], "9:10"); + assert_eq!(linked["style"]["fontSize"].as_f64(), Some(24.0), "the linked style beats the copies below it"); + let outer = find(find(&value["document"], "Outer").unwrap(), "Label").unwrap(); + assert_eq!(outer["styles"]["text"], "9:10"); + assert_eq!(outer["style"]["fontSize"].as_f64(), Some(30.0), "an override above the link is kept"); +} + +#[test] +fn a_row_restating_a_nested_level_loses_to_the_instances_own_row() { + let label = vec![("fontName", font("Inter")), ("fontSize", Kv::Float(14.0)), ("textData", chars("Hi"))]; + let value = rest(vec![ + node(20, "SYMBOL", "Main", Some(2), vec![]), + node(21, "TEXT", "Label", Some(20), label), + // The own row comes first on the wire; the restated nested-level row after it. + instance( + 30, + "Inst", + 2, + 20, + vec![row(&[21], vec![("fontSize", Kv::Float(30.0))]), row(&[21], vec![("fontSize", Kv::Float(12.0)), ("overrideLevel", Kv::Int(1))])], + ), + ]); + let label = find(find(&value["document"], "Inst").unwrap(), "Label").unwrap(); + assert_eq!(label["style"]["fontSize"].as_f64(), Some(30.0)); +} + #[test] fn a_text_property_value_drops_the_run_styles_made_for_the_main_text() { let ids = [0u64, 0, 0, 1, 1, 1].map(Kv::Uint).to_vec(); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index a46c3924..96301dc3 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-10-29 · **Change:** minor (`.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-10-29 · **Change:** minor (`.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 @@ -17,6 +17,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| +| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks with region-specific fill paints (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes and shapes with text keep their generated backgrounds, styled text, text paints, saved child bounds and sticky author lines as editable frame children, including saved vector networks without geometry paths, outer fill/stroke outlines when generated geometry is damaged, and outer body text when its generated child is unusable even if an author survives (#1341, #1397); connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message; since 2026-10-29, style links that name a library style by asset key and version (the file's own copy of it), with override rows above the linking one still winning, and override rows restating a nested level's overrides applied at that level, below the instance's own (#1234). Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, live style and variable links for vector region fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed), `crates/engine/src/shapebuilder_region_paints_tests.rs`, and by reading back what the export writes; the FigJam report sample from #1341 checked locally only; no representative real-world corpus yet | read tail 5–10, write: checks against real files 5–15 | | `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks with region-specific fill paints (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes and shapes with text keep their generated backgrounds, styled text, text paints, saved child bounds and sticky author lines as editable frame children, including saved vector networks without geometry paths, outer fill/stroke outlines when generated geometry is damaged, and outer body text when its generated child is unusable even if an author survives (#1341, #1397); connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message; since 2026-10-29, the line breaks the file saved with a text, followed until an edit or a resize could move one, or where a line would come out more than 3% wider than its box in the fonts at hand (#1236). Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, live style and variable links for vector region fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed), `crates/engine/src/shapebuilder_region_paints_tests.rs`, and by reading back what the export writes; the FigJam report sample from #1341 checked locally only; no representative real-world corpus yet | read tail 5–10, write: checks against real files 5–15 | | `.penpot` (Penpot 2.x ZIP export) | — | **yes, offline** (open, import, drop, CLI, MCP) | **yes, offline** (export, CLI convert) | Editable layers, supported flex/grid layout, components, tokens and prototypes using the existing model. Unsupported layouts retain saved positions and bounded metadata; toggle-overlay and embedded font binaries are reported. Font references remain editable; no downloads. Synthetic regression coverage; live Penpot and cross-platform QA remain open. See [Penpot reference](penpot.md). | further live Penpot and cross-platform QA | | `.sketch` (Sketch 48 and newer) | import (file browser ▸ Import or drop, desktop app too; converted to a Figma file, symbols as components; per the help center) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP; Open With from Finder, Explorer and Linux file managers once installed; recent files show its `previews/preview.png`) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, stretching filling every item across, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides; from Sketch 64, before which a group's fill draws nothing), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); operands' own borders ignored as Sketch does; open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (their image under their style's fills; colour adjustments as image filters with Sketch's tone curve, contrast and then brightness added), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients with Sketch's geometry, gradients mixed in OKLab or OKLCH as sRGB stops along the same colours, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer, motion and background blurs (Sketch's radius is the standard deviation), opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does, and its opacity fades it and what it clips together; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, also on shapes' image fills, layer and text styles, nested paths), clipping only where the master clips (`clippingBehavior` 1), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only when the master includes them in both instances and exports, as on Sketch's canvas and renders; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids (hidden, as Sketch shows them only in its layout view). Instances of a stack laid out at their size and texts an override changed measured again on open, an auto-width one growing from its aligned edge. Warnings: noise fills, OKLab and OKLCH gradients, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 68 and 83 (70,000 layers open in ~3 s, debug build; its edition saved by Sketch 47 is refused, as it should be); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 69 1.1% on average and at most 4.4% (text in IBM Plex, which the file embeds and FigCraft doesn't use yet), a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 0.9% on average (at most 1.9%); the rules for master backgrounds, symbol clipping, mask opacity, bitmap fills, image overrides, stretching stacks, blur radii, colour adjustments, gradient interpolation and text growth checked against Sketch's renders of files built for each case (black-box, synthetic) | 5–10 (Smart Layout as auto layout, motion and zoom blurs, several borders of different widths; fonts embedded in the file once `.figcraft` documents can carry fonts: until then they would only last the session). Known limitations: a plain group (not a frame) that fills a stack keeps its layers' size where Sketch stretches them; a font Sketch can't find draws in the installed family's own weight (Sketch falls back to a regular face); text in the system SF Pro comes out a few percent narrower than Sketch's; macOS's reserved system fonts (PingFang) aren't found; brightness past about +0.4 is approximated (image filters can't add); OKLab gradients become sRGB stops (FigCraft gradients mix in sRGB) | diff --git a/docs/roadmap.md b/docs/roadmap.md index 4cd37b0c..a0d3b0b2 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (`.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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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-10-28 · **Change:** minor (`.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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M6, #1234: `.fig` import resolves style links that name a library style by its asset key and version instead of a guid (the file's own copy of the style), as variable links already were; text in instances of library components kept a stale copy of the style's values and no link. The linked text style beats the copies of its values on the layer and on the override rows below the row that links it, and rows above that row (an outer instance's size, say) still win. Override rows that restate a nested level's overrides (`overrideLevel`) apply at that level, below the instance's own rows, instead of in wire order. Synthetic regression tests in `text_style_tests`. Numbers unchanged | | 2026-10-29 | minor | M6, #1236 (gap 1): imported `.fig` text keeps the line breaks the file saved with it (`derivedTextData`), so a font whose advances differ slightly from the file's (another build of the family, a stand-in) no longer wraps a word early or splits one in a narrow box. The breaks hold only while the box width and everything that moves a break are as saved (characters, fonts, sizes, spacing, case, axes, features, lists, paragraph settings; paints, links and vertical settings don't count), and are given up where a line would come out more than 3% (+1 px) wider than its box; they are kept in `.figcraft`. The line breaker was checked and is unchanged: trailing spaces and the letter spacing after a line's last glyph already stay out of the fit test, box widths aren't rounded and the tolerance is 1e-6 px, so the differences come from the fonts. Synthetic tests in `saved_breaks_tests` and `fig::text_wrap_tests`; not checked on real files. Numbers unchanged | | 2026-10-29 | minor | #754, #1025: the Missing fonts window offers Download for a family the suite's font store has (Google Fonts, Noto: craft-libs' `craft-font-store`, its pinned indexes compiled in). The first Download asks the user to allow downloading fonts (off until then; the setting is the Craft apps' shared one); the files download on a thread of their own, each checked against the index's size and sha256 before it is installed into the shared font folder, which the font scan now reads; the row shows progress or why it failed, and once installed the file's text fits the real font and the family leaves the list. `font.download {family}` and `font.downloads` are commands, but no command turns downloading on; agents install only while the user has it on. Desktop only. No cancel, no turning it off from FigCraft, no substitute picker. Tests: `fonts::download::tests`, `missing_fonts_tests`. Numbers unchanged | | 2026-10-29 | minor | Web wasm size (#1472): the release wasm is 23,915,213 bytes (22.8 MiB), down from 25,982,540 (24.8 MiB), 1.2 MiB under the gate. Catalog rows hold their plural forms behind a reference (two words, not eight) and the browser build compiles with wasm SIMD, so the glyph rasterizer's scalar fallback is left out; the 24 MiB gate in `packaging/web/package.sh` is restored. The browser now needs WebAssembly SIMD (Safari 16.4 or later). Numbers unchanged. | From bc354430ef669f42a5d4256a498e4bc908c5ac6f Mon Sep 17 00:00:00 2001 From: Carbocode Date: Thu, 29 Oct 2026 11:41:54 +0100 Subject: [PATCH 12/63] M1: preserve fractional corner smoothing across drags --- ROADMAP.md | 6 ++-- crates/ui-egui/src/corner_individual_tests.rs | 32 +++++++++++++++++++ crates/ui-egui/src/panels/right/paints.rs | 2 +- docs/ui-parity.md | 6 ++-- 4 files changed, 39 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8de2d7fa..ffcf0ad1 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-29 · **Change:** minor (Corner smoothing: a visible icon supports dragging the percentage with the shared field gesture; `.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-10-29 · **Change:** minor (Corner smoothing: a visible icon supports dragging the percentage with the shared field gesture, preserving fractional values across drags; `.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,7 +132,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log -- **2026-10-29** · M1: Corner smoothing has an original, code-drawn icon with the same 24 pt drag target as the other number fields. Dragging it changes the percentage live, Shift and Alt use the shared rates, the value stays within 0–100%, and the gesture is one undo step. The field is named for screen readers. Regression: `corner_individual_tests`. Numbers unchanged. +- **2026-10-29** · M1: Corner smoothing has an original, code-drawn icon with the same 24 pt drag target as the other number fields. Dragging it changes the percentage live, successive drags preserve fractional percentages, Shift and Alt use the shared rates, the value stays within 0–100%, and the gesture is one undo step. The field is named for screen readers. Regression: `corner_individual_tests`. Numbers unchanged. - **2026-10-28** · M3, #922: an instance scaled with the Scale tool (or `transform.scale`) keeps its layers' scaled size and points when its main is rebuilt. The scaled layers' new size and place were read as what constraints give (an ancestor resized in the same edit), so no override was kept, and adding a layer to the main put a 14 px icon's 24 px triangle back. The Scale tool now records them as `size` and `position` overrides on layers inside instances; strokes already kept theirs. The report's own sequence (clearing effects on the component holding the icon) no longer reproduces on main. Regressions for a scaled instance, one scaled with the tool (with undo) and a scaled icon nested in a component and its instances. Numbers unchanged. - **2026-10-28** · M6, #1059: `.fig` import copies each image carried inside the message once per blob, shared by every hash that names it, instead of once per hash: a file pointing many hashes at one large blob no longer multiplies it in memory (100 hashes on a 250 MB blob were 25 GB). Regression test. Numbers unchanged. @@ -357,7 +357,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| -| 2026-10-29 | minor | M1: Corner smoothing gets an original code-drawn icon, the shared 24 pt scrub target and an accessible name. Live percentage changes, Shift/Alt rates, 0–100% clamping and one-step undo/redo have headless pointer regression coverage (`corner_individual_tests`). Numbers unchanged | +| 2026-10-29 | minor | M1: Corner smoothing gets an original code-drawn icon, the shared 24 pt scrub target and an accessible name. Live percentage changes, fractional baselines across consecutive drags, Shift/Alt rates, 0–100% clamping and one-step undo/redo have headless pointer regression coverage (`corner_individual_tests`). Numbers unchanged | | 2026-10-29 | minor | M6, #1236 (gap 1): imported `.fig` text keeps the line breaks the file saved with it (`derivedTextData`), so a font whose advances differ slightly from the file's (another build of the family, a stand-in) no longer wraps a word early or splits one in a narrow box. The breaks hold only while the box width and everything that moves a break are as saved (characters, fonts, sizes, spacing, case, axes, features, lists, paragraph settings; paints, links and vertical settings don't count), and are given up where a line would come out more than 3% (+1 px) wider than its box; they are kept in `.figcraft`. The line breaker was checked and is unchanged: trailing spaces and the letter spacing after a line's last glyph already stay out of the fit test, box widths aren't rounded and the tolerance is 1e-6 px, so the differences come from the fonts. Synthetic tests in `saved_breaks_tests` and `fig::text_wrap_tests`; not checked on real files. Numbers unchanged | | 2026-10-29 | minor | #754, #1025: the Missing fonts window offers Download for a family the suite's font store has (Google Fonts, Noto: craft-libs' `craft-font-store`, its pinned indexes compiled in). The first Download asks the user to allow downloading fonts (off until then; the setting is the Craft apps' shared one); the files download on a thread of their own, each checked against the index's size and sha256 before it is installed into the shared font folder, which the font scan now reads; the row shows progress or why it failed, and once installed the file's text fits the real font and the family leaves the list. `font.download {family}` and `font.downloads` are commands, but no command turns downloading on; agents install only while the user has it on. Desktop only. No cancel, no turning it off from FigCraft, no substitute picker. Tests: `fonts::download::tests`, `missing_fonts_tests`. Numbers unchanged | | 2026-10-29 | minor | Web wasm size (#1472): the release wasm is 23,915,213 bytes (22.8 MiB), down from 25,982,540 (24.8 MiB), 1.2 MiB under the gate. Catalog rows hold their plural forms behind a reference (two words, not eight) and the browser build compiles with wasm SIMD, so the glyph rasterizer's scalar fallback is left out; the 24 MiB gate in `packaging/web/package.sh` is restored. The browser now needs WebAssembly SIMD (Safari 16.4 or later). Numbers unchanged. | diff --git a/crates/ui-egui/src/corner_individual_tests.rs b/crates/ui-egui/src/corner_individual_tests.rs index 7a68768a..e76f2f57 100644 --- a/crates/ui-egui/src/corner_individual_tests.rs +++ b/crates/ui-egui/src/corner_individual_tests.rs @@ -107,6 +107,38 @@ fn corner_smoothing_icon_scrubs_with_modifiers_and_one_undo_step() { } } +/// Fine drags keep their fractional baseline across gestures, including a later plain drag. +#[test] +fn consecutive_smoothing_drags_preserve_fractional_percentages() { + let (mut u, r) = scene(); + u.app.run("node.set", json!({"ids": [r], "props": {"cornerSmoothing": 0.5}})); + let toggle = named_button(&mut u, "Individual corners"); + click(&mut u, toggle); + let history = u.app.session.undo_depth().0; + let smoothing = |u: &Ui| u.app.session.doc().unwrap().node(r).unwrap().corners.smoothing; + for (step, (dx, modifiers, want)) in + [(16.0, Modifiers::ALT, 0.516), (16.0, Modifiers::ALT, 0.532), (8.0, Modifiers::NONE, 0.612)].into_iter().enumerate() + { + let from = u.ctx.read_response(Id::unique("smoothing").with("lead")).unwrap().rect.center(); + u.frame_with(vec![Event::PointerMoved(from)], modifiers); + u.frame_with(vec![Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers }], modifiers); + for k in 1..=4 { + u.frame_with(vec![Event::PointerMoved(pos2(from.x + dx * k as f32 / 4.0, from.y))], modifiers); + } + u.frame_with( + vec![Event::PointerButton { pos: pos2(from.x + dx, from.y), button: egui::PointerButton::Primary, pressed: false, modifiers }], + modifiers, + ); + u.frame(vec![]); + assert!((smoothing(&u) - want).abs() < 1e-9, "gesture {step}: expected {want}, got {}", smoothing(&u)); + assert_eq!(u.app.session.undo_depth().0, history + step + 1); + } + for want in [0.532, 0.516, 0.5] { + u.app.session.undo().unwrap(); + assert!((smoothing(&u) - want).abs() < 1e-9, "undo restores the precise value"); + } +} + /// The fields sit at their corners, and typing in the bottom right one sets only that corner in a /// single undo step (#1002: the bottom row showed bottom right on the left). #[test] diff --git a/crates/ui-egui/src/panels/right/paints.rs b/crates/ui-egui/src/panels/right/paints.rs index 5f69098e..095649ec 100644 --- a/crates/ui-egui/src/panels/right/paints.rs +++ b/crates/ui-egui/src/panels/right/paints.rs @@ -113,7 +113,7 @@ pub(super) fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[N } }); } - let sm = common(doc, sel, |n| (n.corners.smoothing * 100.0).round()); + let sm = common(doc, sel, |n| n.corners.smoothing * 100.0); widgets::sublabels(ui, tr("Corner smoothing"), None); row(ui, 32.0, |ui| { let e = widgets::number_field(ui, Id::unique("smoothing"), Lead::Icon(Icon::CornerSmoothing, tr("Corner smoothing")), sm, "%", col, 1.0); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 4a1fa256..74cdd796 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-10-29 · **Change:** minor (Corner smoothing: an original icon and the shared number-field drag gesture; 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; 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; 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; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; 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) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; 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; 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; 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; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; 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) · **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: @@ -68,7 +68,7 @@ interaction feel not yet compared. | Ctrl to suspend snapping while dragging (macOS) | done | Control on macOS (Cmd keeps working), Ctrl elsewhere; moving, resizing and drawing | | Measure (Alt hover, Cmd+Alt nested, Shift+Alt padding/gaps) | done | labels never overlap; the arrows still nudge while measuring (Shift: the big step) and the distances follow; the selection's handles and size label hide while measuring (`measure_nudge_tests`, #894) | | Pixel grid snapping, pixel preview, 2x preview | done | moves, resizes and new shapes land on whole pixels; the pixel grid shows from 400% (`view_behaviour_tests`) | -| Numeric fields: arithmetic, scrubbing by dragging the label, mixed values | done | Numeric panels use `widgets::number_field`; Corner smoothing has an original code-drawn icon, a 24 pt drag target and an accessible name, with live changes, Shift/Alt rates, 0–100% clamping and one-step undo/redo (`corner_individual_tests`); Typography line height, letter spacing and paragraph spacing share its leading-icon scrub logic (#373), retaining their existing unit parsing, Auto/Mixed values, variable chips and selected-character targeting (`typography_scrub_tests`). Scrubbing the label: a step a point (1 px; 1% of opacity, 1° of rotation), ten with Shift, a tenth with Alt, each stretch at the rate held while it was dragged, one undo step when let go; past the edge of the screen it goes on where the platform reports the mouse's own motion (desktop), otherwise it stops there. Arrow keys: Up/Down step a pixel field by the small nudge and Shift by the big nudge (the preferences), other fields by their own step and ten of it; they work while typing (from what is typed) and with the keyboard on the field. Typing: `+ - * /`, brackets, units (`px`, `°`, `%`, `x`, `ms`), a decimal comma; `+10`, `*2`, `/4` are relative to the value, and `50%`, `+10%`, `-10%` too in a field that isn't a percentage. Enter, Tab or a click elsewhere commits (Tab and Shift+Tab move to the next and previous control), Escape restores; a field left as it was commits nothing (no undo step, and a rounded display isn't written back). Mixed values show "Mixed": a number sets them all, a relative edit or an arrow key goes to each layer from its own value in one undo step, for X, Y, W, H, rotation, opacity, corner radius and stroke weight (elsewhere `+10` on Mixed sets 10 and `*2` does nothing) (`field_tests`). Left: scrubbing a Mixed field (nothing happens), `x2`-style suffix edits, the pointer isn't wrapped to the other side of the screen; rates not compared with Figma's | +| Numeric fields: arithmetic, scrubbing by dragging the label, mixed values | done | Numeric panels use `widgets::number_field`; Corner smoothing has an original code-drawn icon, a 24 pt drag target and an accessible name, with live changes, fractional values preserved across consecutive drags, Shift/Alt rates, 0–100% clamping and one-step undo/redo (`corner_individual_tests`); Typography line height, letter spacing and paragraph spacing share its leading-icon scrub logic (#373), retaining their existing unit parsing, Auto/Mixed values, variable chips and selected-character targeting (`typography_scrub_tests`). Scrubbing the label: a step a point (1 px; 1% of opacity, 1° of rotation), ten with Shift, a tenth with Alt, each stretch at the rate held while it was dragged, one undo step when let go; past the edge of the screen it goes on where the platform reports the mouse's own motion (desktop), otherwise it stops there. Arrow keys: Up/Down step a pixel field by the small nudge and Shift by the big nudge (the preferences), other fields by their own step and ten of it; they work while typing (from what is typed) and with the keyboard on the field. Typing: `+ - * /`, brackets, units (`px`, `°`, `%`, `x`, `ms`), a decimal comma; `+10`, `*2`, `/4` are relative to the value, and `50%`, `+10%`, `-10%` too in a field that isn't a percentage. Enter, Tab or a click elsewhere commits (Tab and Shift+Tab move to the next and previous control), Escape restores; a field left as it was commits nothing (no undo step, and a rounded display isn't written back). Mixed values show "Mixed": a number sets them all, a relative edit or an arrow key goes to each layer from its own value in one undo step, for X, Y, W, H, rotation, opacity, corner radius and stroke weight (elsewhere `+10` on Mixed sets 10 and `*2` does nothing) (`field_tests`). Left: scrubbing a Mixed field (nothing happens), `x2`-style suffix edits, the pointer isn't wrapped to the other side of the screen; rates not compared with Figma's | | Transform box at small sizes | done | a press inside a layer smaller than its handles moves it, the nearest handle wins, handles answer just outside and are hidden under 16 px on screen; cursors follow each handle and the box's rotation | | Locked layers | done | selectable from the layers panel; no handles, not dragged with the rest of a selection | | Smart selection spacing | partial | with layers in a row, a column or an aligned grid selected, the Layout section shows horizontal and vertical spacing ("Mixed" until set; typed, scrubbed, fractional and negative) and pink handles in the gaps on the canvas drag them, one undo step, Escape cancels (`arrange.spacing`; sizes, parents and offsets within each track kept; at most 2,000 layers; flow children, locked layers and layers inside instances left out). No reorder rings for swapping layers; checked by its author on macOS and in the app on Windows (#402) | @@ -174,7 +174,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| -| 2026-10-29 | minor | M1: Corner smoothing has an original icon and a 24 pt drag target, using the shared field gesture (live changes, Shift/Alt rates, 0–100% clamping, one undo step). The field now has an accessible name (`corner_individual_tests`). Numbers unchanged | +| 2026-10-29 | minor | M1: Corner smoothing has an original icon and a 24 pt drag target, using the shared field gesture (live changes, fractional values preserved across consecutive drags, Shift/Alt rates, 0–100% clamping, one undo step). The field now has an accessible name (`corner_individual_tests`). Numbers unchanged | | 2026-10-29 | minor | Crop image (#1464): Object ▸ Crop image, the right-click menu and a crop button on the image fill row enter crop mode on the canvas (`image.crop`; the image dimmed outside the layer, corner brackets and edge bars on its box; Enter, Escape or a click outside finishes). The row stays done | | 2026-10-29 | minor | Toolbar (#1210): every state of the pill (tools, vector edit, shape builder, view only) is laid out on one 32 pt cell, 8 pt in from its ends and 8 pt between its items, with a 1 pt divider and 14 pt mode-switcher marks; the Resources button is gone, as in UI3 (the left rail's Assets tab, Shift+I, Alt+2 and View ▸ Panels ▸ Assets open the Assets panel); a narrow window still scrolls the pill (#1209) (`resources_tests`, `toolbar` tests). Numbers unchanged | | 2026-10-27 | minor | The Design panel's sections sit on one grid (#858): 16 pt left and 8 pt right margins, two 88 pt columns with 8 pt gutters and a 24 pt button column, 24 pt rows, 11 pt semibold section titles over 10 pt captions, 4 pt between the two buttons at a row's end; the panel's frame no longer carries a stroke (it counts as margin), so an icon's box ends 8 pt from the window edge. Checked in the app on Windows over the control channel | From 0971d096db18aa107c5f3d1f83af4ac7f89ea2c3 Mon Sep 17 00:00:00 2001 From: BFlat Date: Thu, 29 Oct 2026 17:42:53 +0700 Subject: [PATCH 13/63] M8: the progressive JPEG check guards file thumbnails too (#1517) The header check from #1491 moves to figcraft_format::jpeg so the format crate and the renderer share it, and decode_thumbnail now refuses a progressive JPEG that samples a component more finely than its first (zune-jpeg 0.5.15 asserts on it; in the browser a panic aborts the page). A crafted file's thumbnail on the home screen and in recent files shows no picture instead of crashing. Regression test through decode_thumbnail and archive_preview. --- Cargo.lock | 1 + ROADMAP.md | 3 +- crates/format/src/jpeg.rs | 49 ++++++++++ crates/format/src/jpeg_fixtures.rs | 86 +++++++++++++++++ crates/format/src/jpeg_tests.rs | 65 +++++++++++++ crates/format/src/lib.rs | 3 + crates/format/src/thumbnail.rs | 3 + crates/render/Cargo.toml | 1 + crates/render/src/images.rs | 47 +-------- crates/render/src/jpeg_sampling_tests.rs | 115 ++--------------------- docs/file-format-parity.md | 3 +- docs/roadmap.md | 3 +- 12 files changed, 221 insertions(+), 158 deletions(-) create mode 100644 crates/format/src/jpeg.rs create mode 100644 crates/format/src/jpeg_fixtures.rs create mode 100644 crates/format/src/jpeg_tests.rs diff --git a/Cargo.lock b/Cargo.lock index ad774f84..335b0d62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1904,6 +1904,7 @@ dependencies = [ "crc32fast", "figcraft-color", "figcraft-doc", + "figcraft-format", "figcraft-geom", "figcraft-pathops", "figcraft-text", diff --git a/ROADMAP.md b/ROADMAP.md index d1fa8e0f..1abbd777 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-29 · **Change:** minor (`.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-10-29 · **Change:** minor (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 @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | patch | M8: the progressive JPEG check from #1491 (a frame sampling a component more finely than its first, which the JPEG decoder asserts on, and in the browser a panic aborts the page) moved to `figcraft_format::jpeg` and now also guards the pictures files carry (`thumbnail::decode_thumbnail`: the home screen and recent files), so a crafted `.figcraft`, `.fig` or `.sketch` thumbnail or image file shows no picture instead of crashing. The canvas, exports and SVG/PDF keep it through the renderer. Regression tests in `jpeg_tests` (format) and `jpeg_sampling_tests` (render). Numbers unchanged | | 2026-10-29 | minor | M6, #1234: `.fig` import resolves style links that name a library style by its asset key and version instead of a guid (the file's own copy of the style), as variable links already were; text in instances of library components kept a stale copy of the style's values and no link. The linked text style beats the copies of its values on the layer and on the override rows below the row that links it, and rows above that row (an outer instance's size, say) still win. Override rows that restate a nested level's overrides (`overrideLevel`) apply at that level, below the instance's own rows, instead of in wire order. Synthetic regression tests in `text_style_tests`. Numbers unchanged | | 2026-10-29 | minor | M6, #1236 (gap 1): imported `.fig` text keeps the line breaks the file saved with it (`derivedTextData`), so a font whose advances differ slightly from the file's (another build of the family, a stand-in) no longer wraps a word early or splits one in a narrow box. The breaks hold only while the box width and everything that moves a break are as saved (characters, fonts, sizes, spacing, case, axes, features, lists, paragraph settings; paints, links and vertical settings don't count), and are given up where a line would come out more than 3% (+1 px) wider than its box; they are kept in `.figcraft`. The line breaker was checked and is unchanged: trailing spaces and the letter spacing after a line's last glyph already stay out of the fit test, box widths aren't rounded and the tolerance is 1e-6 px, so the differences come from the fonts. Synthetic tests in `saved_breaks_tests` and `fig::text_wrap_tests`; not checked on real files. Numbers unchanged | | 2026-10-29 | minor | #754, #1025: the Missing fonts window offers Download for a family the suite's font store has (Google Fonts, Noto: craft-libs' `craft-font-store`, its pinned indexes compiled in). The first Download asks the user to allow downloading fonts (off until then; the setting is the Craft apps' shared one); the files download on a thread of their own, each checked against the index's size and sha256 before it is installed into the shared font folder, which the font scan now reads; the row shows progress or why it failed, and once installed the file's text fits the real font and the family leaves the list. `font.download {family}` and `font.downloads` are commands, but no command turns downloading on; agents install only while the user has it on. Desktop only. No cancel, no turning it off from FigCraft, no substitute picker. Tests: `fonts::download::tests`, `missing_fonts_tests`. Numbers unchanged | diff --git a/crates/format/src/jpeg.rs b/crates/format/src/jpeg.rs new file mode 100644 index 00000000..ce0dd79b --- /dev/null +++ b/crates/format/src/jpeg.rs @@ -0,0 +1,49 @@ +//! A JPEG header check run before any JPEG from a file is decoded: on the canvas and in exports +//! (`figcraft-render`) and for the pictures files carry ([`crate::thumbnail`]). + +/// Whether `bytes` are a progressive JPEG whose frame samples some component more finely (across +/// or down) than its first. zune-jpeg 0.5.15, the decoder behind `image`, asserts in its upsampler +/// on such a frame (etemesi254/zune-image#236; fixed there by 36007420, unreleased as of +/// 0.5.16-rc3), and in the browser a panic aborts the page. Until the fix ships, such a JPEG is +/// refused from its header: the decoder refuses the larger ones itself, so nothing that decoded is +/// lost. Baseline frames of the same shape decode and are left to it. +/// shortcut: remove this check (and its callers' early return) once `Cargo.lock` has zune-jpeg +/// 0.5.16 or later. +pub fn finer_than_first(bytes: &[u8]) -> bool { + let Some((marker, frame)) = frame(bytes) else { return false }; + if !matches!(marker, 0xC2 | 0xC6 | 0xCA | 0xCE) { + return false; + } + // Length (2), precision (1), height (2), width (2), components (1), then id, sampling and + // table for each component. + let n = usize::from(frame.get(7).copied().unwrap_or(0)); + let sampling = |k: usize| frame.get(9 + 3 * k).map(|b| (b >> 4, b & 0x0F)); + let Some((h0, v0)) = sampling(0) else { return false }; + (1..n).any(|k| sampling(k).is_some_and(|(h, v)| h > h0 || v > v0)) +} + +/// A JPEG's start-of-frame marker and the segment after it (from its length bytes), found as the +/// decoders find it: fill bytes are skipped and markers without a length stepped over. `None` for +/// anything but a JPEG, or one whose scan, end or bytes come before a frame. +fn frame(bytes: &[u8]) -> Option<(u8, &[u8])> { + if !bytes.starts_with(&[0xFF, 0xD8]) { + return None; + } + let mut at = 2; + loop { + if *bytes.get(at)? != 0xFF { + return None; + } + let marker = *bytes.get(at + 1)?; + match marker { + 0xFF => at += 1, + 0x01 | 0xD0..=0xD8 => at += 2, + 0xD9 | 0xDA => return None, + 0xC0..=0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF => return Some((marker, bytes.get(at + 2..)?)), + _ => { + let len = usize::from(u16::from_be_bytes([*bytes.get(at + 2)?, *bytes.get(at + 3)?])); + at = at.checked_add(2)?.checked_add(len)?; + } + } + } +} diff --git a/crates/format/src/jpeg_fixtures.rs b/crates/format/src/jpeg_fixtures.rs new file mode 100644 index 00000000..d110e706 --- /dev/null +++ b/crates/format/src/jpeg_fixtures.rs @@ -0,0 +1,86 @@ +// Synthetic JPEGs for the header check in `jpeg.rs`, shared by its tests here and the decoder +// tests in `figcraft-render` (`include!`d by both). + +/// An 8 × 8 progressive RGB JPEG with every component sampled 1 × 1, as jpeg-encoder 0.6 writes it +/// at quality 50 (JFIF header, the standard tables, three scans). Its frame header (SOF2) starts at +/// byte 20; the second component's sampling byte is [`SECOND_SAMPLING`], the third's follows 3 on. +const TINY_PROGRESSIVE: &[u8] = &[ + 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x02, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xC2, 0x00, 0x11, + 0x08, 0x00, 0x08, 0x00, 0x08, 0x03, 0x00, 0x11, 0x00, 0x01, 0x11, 0x01, 0x02, 0x11, 0x01, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x10, 0x0B, 0x0C, 0x0E, + 0x0C, 0x0A, 0x10, 0x0E, 0x0D, 0x0E, 0x12, 0x11, 0x10, 0x13, 0x18, 0x28, 0x1A, 0x18, 0x16, 0x16, 0x18, 0x31, 0x23, 0x25, 0x1D, 0x28, 0x3A, 0x33, + 0x3D, 0x3C, 0x39, 0x33, 0x38, 0x37, 0x40, 0x48, 0x5C, 0x4E, 0x40, 0x44, 0x57, 0x45, 0x37, 0x38, 0x50, 0x6D, 0x51, 0x57, 0x5F, 0x62, 0x67, 0x68, + 0x67, 0x3E, 0x4D, 0x71, 0x79, 0x70, 0x64, 0x78, 0x5C, 0x65, 0x67, 0x63, 0xFF, 0xDB, 0x00, 0x43, 0x01, 0x11, 0x12, 0x12, 0x18, 0x15, 0x18, 0x2F, + 0x1A, 0x1A, 0x2F, 0x63, 0x42, 0x38, 0x42, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, + 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, + 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0xFF, 0xC4, 0x00, 0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00, + 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, + 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, 0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, + 0x33, 0x62, 0x72, 0x82, 0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, + 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, + 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, + 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, + 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1, + 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xC4, 0x00, 0x1F, 0x01, 0x00, 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x11, 0x00, + 0x02, 0x01, 0x02, 0x04, 0x04, 0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, 0x01, 0x02, 0x77, 0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, + 0x06, 0x12, 0x41, 0x51, 0x07, 0x61, 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xA1, 0xB1, 0xC1, 0x09, 0x23, 0x33, 0x52, 0xF0, 0x15, + 0x62, 0x72, 0xD1, 0x0A, 0x16, 0x24, 0x34, 0xE1, 0x25, 0xF1, 0x17, 0x18, 0x19, 0x1A, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x35, 0x36, 0x37, 0x38, 0x39, + 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, + 0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, + 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, + 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, + 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x6F, 0xFF, 0xDA, 0x00, 0x08, + 0x01, 0x01, 0x11, 0x00, 0x00, 0x00, 0x3F, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x02, 0x11, 0x00, 0x00, 0x00, 0x7F, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x00, + 0x00, 0x01, 0x14, 0x00, 0x4D, 0xB0, 0x97, 0x8E, 0x45, 0x01, 0x08, 0x03, 0x70, 0xAF, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x11, 0x01, 0x14, 0x00, + 0xAD, 0x17, 0xA5, 0x4A, 0x6C, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x02, 0x11, 0x01, 0x14, 0x00, 0x8A, 0x75, 0xA1, 0xCB, 0x6D, 0x62, 0x7F, 0xFF, 0xDA, + 0x00, 0x08, 0x01, 0x00, 0x00, 0x15, 0x29, 0x00, 0xE0, 0x75, 0x1C, 0x8C, 0x74, 0xAF, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x11, 0x15, 0x29, 0x00, + 0x3F, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x02, 0x11, 0x15, 0x29, 0x00, 0x3F, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x00, 0x00, 0x2A, 0x3F, 0x00, 0xC8, 0x3C, + 0x7E, 0x1F, 0x79, 0x07, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x11, 0x2A, 0x3F, 0x00, 0xFE, 0x96, 0x8E, 0x3F, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x02, + 0x11, 0x2A, 0x3F, 0x00, 0xF9, 0x36, 0xFF, 0x00, 0x99, 0xFB, 0xDF, 0xFF, 0xD9, +]; +const SECOND_SAMPLING: usize = 34; + +/// An 8 × 8 baseline CMYK JPEG as jpeg-encoder 0.6 writes it at quality 50 with 2 × 2 sampling: +/// its black is sampled 2 × 2 against 1 × 1 for the others (SOF0 at byte 36), a well-formed file +/// of the shape the check is about, in a baseline frame. +const TINY_BASELINE_CMYK: &[u8] = &[ + 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x02, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xEE, 0x00, 0x0E, + 0x41, 0x64, 0x6F, 0x62, 0x65, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xC0, 0x00, 0x14, 0x08, 0x00, 0x08, 0x00, 0x08, 0x04, 0x00, 0x11, + 0x01, 0x01, 0x11, 0x01, 0x02, 0x11, 0x01, 0x03, 0x22, 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x10, 0x0B, 0x0C, 0x0E, 0x0C, 0x0A, 0x10, 0x0E, 0x0D, + 0x0E, 0x12, 0x11, 0x10, 0x13, 0x18, 0x28, 0x1A, 0x18, 0x16, 0x16, 0x18, 0x31, 0x23, 0x25, 0x1D, 0x28, 0x3A, 0x33, 0x3D, 0x3C, 0x39, 0x33, 0x38, + 0x37, 0x40, 0x48, 0x5C, 0x4E, 0x40, 0x44, 0x57, 0x45, 0x37, 0x38, 0x50, 0x6D, 0x51, 0x57, 0x5F, 0x62, 0x67, 0x68, 0x67, 0x3E, 0x4D, 0x71, 0x79, + 0x70, 0x64, 0x78, 0x5C, 0x65, 0x67, 0x63, 0xFF, 0xDB, 0x00, 0x43, 0x01, 0x11, 0x12, 0x12, 0x18, 0x15, 0x18, 0x2F, 0x1A, 0x1A, 0x2F, 0x63, 0x42, + 0x38, 0x42, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, + 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, + 0x63, 0x63, 0x63, 0x63, 0xFF, 0xC4, 0x00, 0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02, + 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, + 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, 0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, 0x72, 0x82, 0x09, + 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, + 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, + 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, + 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, + 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, + 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xC4, 0x00, 0x1F, 0x01, 0x00, 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x11, 0x00, 0x02, 0x01, 0x02, 0x04, 0x04, + 0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, 0x01, 0x02, 0x77, 0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, 0x06, 0x12, 0x41, 0x51, 0x07, + 0x61, 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xA1, 0xB1, 0xC1, 0x09, 0x23, 0x33, 0x52, 0xF0, 0x15, 0x62, 0x72, 0xD1, 0x0A, 0x16, + 0x24, 0x34, 0xE1, 0x25, 0xF1, 0x17, 0x18, 0x19, 0x1A, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, + 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76, + 0x77, 0x78, 0x79, 0x7A, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, + 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, + 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, + 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xDA, 0x00, 0x0E, 0x04, 0x00, 0x11, 0x01, 0x11, 0x02, 0x11, 0x03, 0x00, 0x00, 0x3F, 0x00, 0xEE, 0xA6, 0xAA, 0x2A, + 0xB3, 0x83, 0x95, 0xED, 0x6D, 0x75, 0xBB, 0xF5, 0xB4, 0x97, 0x6D, 0xB6, 0x5A, 0xD9, 0x2B, 0x8D, 0x39, 0x3A, 0xB3, 0x8F, 0x54, 0xFB, 0xCB, 0xAF, + 0xFD, 0xBD, 0x7E, 0x9D, 0xDA, 0xEC, 0x96, 0xB7, 0xE2, 0x95, 0x47, 0x2A, 0x8E, 0x0B, 0x46, 0xB7, 0x77, 0x7A, 0xDD, 0xBE, 0xCD, 0x3F, 0xBD, 0xBF, + 0x2B, 0x6C, 0x0E, 0xC3, 0x6E, 0xE9, 0xB6, 0xCA, 0xE0, 0x07, 0x1B, 0xC9, 0x21, 0x95, 0x4E, 0x72, 0x54, 0x0C, 0x8E, 0x00, 0x62, 0x00, 0x5C, 0x28, + 0x45, 0xEF, 0x51, 0xFF, 0x00, 0x6B, 0xDA, 0xFF, 0x00, 0x76, 0xC7, 0xFF, 0x00, 0x01, 0x27, 0xA1, 0xFF, 0x00, 0xE3, 0xFB, 0xFE, 0xDD, 0x65, 0xFF, + 0x00, 0xD1, 0x31, 0xD6, 0x35, 0x7F, 0xFF, 0xD9, +]; + +fn with_second_sampled(hv: u8) -> Vec { + let mut b = TINY_PROGRESSIVE.to_vec(); + b[SECOND_SAMPLING] = hv; + b +} diff --git a/crates/format/src/jpeg_tests.rs b/crates/format/src/jpeg_tests.rs new file mode 100644 index 00000000..6034f5e4 --- /dev/null +++ b/crates/format/src/jpeg_tests.rs @@ -0,0 +1,65 @@ +//! A progressive JPEG whose frame samples a component more finely than its first is refused from +//! its header, not handed to the decoder (zune-jpeg 0.5.15 asserts on it, and in the browser a +//! panic aborts the page): here for the header check itself and for the pictures files carry. + +use std::io::Cursor; + +use crate::jpeg::finer_than_first; +use crate::thumbnail::{archive_preview, decode_thumbnail}; + +include!("jpeg_fixtures.rs"); + +#[test] +fn the_jpeg_as_written_is_not_finer_than_its_first() { + assert_eq!(TINY_PROGRESSIVE[SECOND_SAMPLING], 0x11); + assert!(!finer_than_first(TINY_PROGRESSIVE)); + // Chroma sampled coarser than luma (the usual way round) isn't finer either. + assert!(!finer_than_first(&{ + let mut b = TINY_PROGRESSIVE.to_vec(); + b[SECOND_SAMPLING - 3] = 0x22; + b + })); +} + +#[test] +fn a_progressive_jpeg_sampling_a_component_finer_than_its_first_is_caught_from_its_header() { + for hv in [0x22, 0x21, 0x12] { + assert!(finer_than_first(&with_second_sampled(hv)), "second component {hv:#x}"); + } + let mut third = TINY_PROGRESSIVE.to_vec(); + third[SECOND_SAMPLING + 3] = 0x22; + assert!(finer_than_first(&third)); +} + +#[test] +fn baseline_frames_and_other_files_are_not_caught() { + // A well-formed baseline frame with its black sampled finer than its cyan: the decoder takes it. + assert!(!finer_than_first(TINY_BASELINE_CMYK)); + // Not JPEGs, or JPEGs that end before their frame. + let mut png = Vec::new(); + image::RgbaImage::from_pixel(4, 4, image::Rgba([1, 2, 3, 255])).write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + assert!(!finer_than_first(&png)); + assert!(!finer_than_first(&TINY_PROGRESSIVE[..19])); + assert!(!finer_than_first(&[0xFF, 0xD8])); + assert!(!finer_than_first(&[])); +} + +/// Without the check in `decode_thumbnail` this test panics in the decoder (an assertion in +/// zune-jpeg's upsampler): a crafted file's thumbnail, on the home screen and in recent files. +#[test] +fn a_thumbnail_sampling_a_component_finer_than_its_first_gives_no_picture() { + for hv in [0x22, 0x21, 0x12] { + assert_eq!(decode_thumbnail(&with_second_sampled(hv), 64), None, "second component {hv:#x}"); + } + let mut zip = Cursor::new(Vec::new()); + let mut z = zip::ZipWriter::new(&mut zip); + z.start_file("thumbnail.png", zip::write::SimpleFileOptions::default()).unwrap(); + std::io::Write::write_all(&mut z, &with_second_sampled(0x22)).unwrap(); + z.finish().unwrap(); + assert_eq!(archive_preview(Cursor::new(zip.into_inner()), 64).picture, None); + // The well-formed ones still give their picture. + for good in [TINY_PROGRESSIVE, TINY_BASELINE_CMYK] { + let t = decode_thumbnail(good, 64).expect("decoded"); + assert_eq!((t.width, t.height), (8, 8)); + } +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 52f12075..a8eaf6b1 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -18,6 +18,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod fig; +pub mod jpeg; pub mod library; pub mod penpot; pub mod rest; @@ -987,6 +988,8 @@ mod image_budget_tests; #[cfg(test)] mod image_tests; #[cfg(test)] +mod jpeg_tests; +#[cfg(test)] mod large_tests; #[cfg(test)] mod staging_tests; diff --git a/crates/format/src/thumbnail.rs b/crates/format/src/thumbnail.rs index 718e8d79..ae7c14cd 100644 --- a/crates/format/src/thumbnail.rs +++ b/crates/format/src/thumbnail.rs @@ -42,6 +42,9 @@ pub fn sketch_preview_png(archive: R) -> Option> { /// Decode a picture (PNG, JPEG, WebP, GIF, BMP or TIFF) no larger than [`MAX_THUMBNAIL_SIDE`] on /// either side, scaled down to at most `fit` pixels on its longer side (never up). pub fn decode_thumbnail(bytes: &[u8], fit: u32) -> Option { + if crate::jpeg::finer_than_first(bytes) { + return None; + } let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?; let mut limits = image::Limits::default(); limits.max_image_width = Some(MAX_THUMBNAIL_SIDE); diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 2dcfda62..5027a69d 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -12,6 +12,7 @@ workspace = true figcraft-geom = { workspace = true } figcraft-color = { workspace = true } figcraft-doc = { workspace = true } +figcraft-format = { workspace = true } figcraft-text = { workspace = true } figcraft-pathops = { workspace = true } tiny-skia = { workspace = true } diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index d41e533d..ba76846b 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -100,7 +100,7 @@ fn decode_in(bytes: &[u8], target: ColorProfile) -> Option<(Pixmap, bool)> { /// tall. pub(crate) fn decode_within(bytes: &[u8], target: ColorProfile, max_side: u32, max_bytes: u64) -> Option<(Pixmap, bool)> { use image::ImageDecoder; - if jpeg_finer_than_first(bytes) { + if figcraft_format::jpeg::finer_than_first(bytes) { return None; } let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?; @@ -384,51 +384,6 @@ pub fn apply_filters(p: &mut Pixmap, f: &ImageFilters) { } } -/// Whether `bytes` are a progressive JPEG whose frame samples some component more finely (across -/// or down) than its first. zune-jpeg 0.5.15, the decoder behind `image`, asserts in its upsampler -/// on such a frame (etemesi254/zune-image#236; fixed there by 36007420, unreleased as of -/// 0.5.16-rc3), and in the browser a panic aborts the page. Until the fix ships, such a JPEG is -/// refused from its header: the decoder refuses the larger ones itself, so nothing that decoded is -/// lost. Baseline frames of the same shape decode and are left to it. -pub(crate) fn jpeg_finer_than_first(bytes: &[u8]) -> bool { - let Some((marker, frame)) = jpeg_frame(bytes) else { return false }; - if !matches!(marker, 0xC2 | 0xC6 | 0xCA | 0xCE) { - return false; - } - // Length (2), precision (1), height (2), width (2), components (1), then id, sampling and - // table for each component. - let n = usize::from(frame.get(7).copied().unwrap_or(0)); - let sampling = |k: usize| frame.get(9 + 3 * k).map(|b| (b >> 4, b & 0x0F)); - let Some((h0, v0)) = sampling(0) else { return false }; - (1..n).any(|k| sampling(k).is_some_and(|(h, v)| h > h0 || v > v0)) -} - -/// A JPEG's start-of-frame marker and the segment after it (from its length bytes), found as the -/// decoders find it: fill bytes are skipped and markers without a length stepped over. `None` for -/// anything but a JPEG, or one whose scan, end or bytes come before a frame. -fn jpeg_frame(bytes: &[u8]) -> Option<(u8, &[u8])> { - if !bytes.starts_with(&[0xFF, 0xD8]) { - return None; - } - let mut at = 2; - loop { - if *bytes.get(at)? != 0xFF { - return None; - } - let marker = *bytes.get(at + 1)?; - match marker { - 0xFF => at += 1, - 0x01 | 0xD0..=0xD8 => at += 2, - 0xD9 | 0xDA => return None, - 0xC0..=0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF => return Some((marker, bytes.get(at + 2..)?)), - _ => { - let len = usize::from(u16::from_be_bytes([*bytes.get(at + 2)?, *bytes.get(at + 3)?])); - at = at.checked_add(2)?.checked_add(len)?; - } - } - } -} - /// Read an image's pixel size without keeping the decode. pub fn dimensions(bytes: &[u8]) -> Option<(u32, u32)> { let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?; diff --git a/crates/render/src/jpeg_sampling_tests.rs b/crates/render/src/jpeg_sampling_tests.rs index e001e702..bfe3f5a3 100644 --- a/crates/render/src/jpeg_sampling_tests.rs +++ b/crates/render/src/jpeg_sampling_tests.rs @@ -3,135 +3,32 @@ //! in its upsampler on such a frame (etemesi254/zune-image#236), and in the browser a panic aborts //! the page. Baseline frames of the same shape are decoded by the decoder itself and left to it. -use std::io::Cursor; +use crate::images; -use super::*; - -/// An 8 × 8 progressive RGB JPEG with every component sampled 1 × 1, as jpeg-encoder 0.6 writes it -/// at quality 50 (JFIF header, the standard tables, three scans). Its frame header (SOF2) starts at -/// byte 20; the second component's sampling byte is [`SECOND_SAMPLING`], the third's follows 3 on. -const TINY_PROGRESSIVE: &[u8] = &[ - 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x02, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xC2, 0x00, 0x11, - 0x08, 0x00, 0x08, 0x00, 0x08, 0x03, 0x00, 0x11, 0x00, 0x01, 0x11, 0x01, 0x02, 0x11, 0x01, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x10, 0x0B, 0x0C, 0x0E, - 0x0C, 0x0A, 0x10, 0x0E, 0x0D, 0x0E, 0x12, 0x11, 0x10, 0x13, 0x18, 0x28, 0x1A, 0x18, 0x16, 0x16, 0x18, 0x31, 0x23, 0x25, 0x1D, 0x28, 0x3A, 0x33, - 0x3D, 0x3C, 0x39, 0x33, 0x38, 0x37, 0x40, 0x48, 0x5C, 0x4E, 0x40, 0x44, 0x57, 0x45, 0x37, 0x38, 0x50, 0x6D, 0x51, 0x57, 0x5F, 0x62, 0x67, 0x68, - 0x67, 0x3E, 0x4D, 0x71, 0x79, 0x70, 0x64, 0x78, 0x5C, 0x65, 0x67, 0x63, 0xFF, 0xDB, 0x00, 0x43, 0x01, 0x11, 0x12, 0x12, 0x18, 0x15, 0x18, 0x2F, - 0x1A, 0x1A, 0x2F, 0x63, 0x42, 0x38, 0x42, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, - 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, - 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0xFF, 0xC4, 0x00, 0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00, - 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, - 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, 0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, - 0x33, 0x62, 0x72, 0x82, 0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, - 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, - 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, - 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, - 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1, - 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xC4, 0x00, 0x1F, 0x01, 0x00, 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, - 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x11, 0x00, - 0x02, 0x01, 0x02, 0x04, 0x04, 0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, 0x01, 0x02, 0x77, 0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, - 0x06, 0x12, 0x41, 0x51, 0x07, 0x61, 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xA1, 0xB1, 0xC1, 0x09, 0x23, 0x33, 0x52, 0xF0, 0x15, - 0x62, 0x72, 0xD1, 0x0A, 0x16, 0x24, 0x34, 0xE1, 0x25, 0xF1, 0x17, 0x18, 0x19, 0x1A, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x35, 0x36, 0x37, 0x38, 0x39, - 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, - 0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, - 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, - 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, - 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x6F, 0xFF, 0xDA, 0x00, 0x08, - 0x01, 0x01, 0x11, 0x00, 0x00, 0x00, 0x3F, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x02, 0x11, 0x00, 0x00, 0x00, 0x7F, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x00, - 0x00, 0x01, 0x14, 0x00, 0x4D, 0xB0, 0x97, 0x8E, 0x45, 0x01, 0x08, 0x03, 0x70, 0xAF, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x11, 0x01, 0x14, 0x00, - 0xAD, 0x17, 0xA5, 0x4A, 0x6C, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x02, 0x11, 0x01, 0x14, 0x00, 0x8A, 0x75, 0xA1, 0xCB, 0x6D, 0x62, 0x7F, 0xFF, 0xDA, - 0x00, 0x08, 0x01, 0x00, 0x00, 0x15, 0x29, 0x00, 0xE0, 0x75, 0x1C, 0x8C, 0x74, 0xAF, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x11, 0x15, 0x29, 0x00, - 0x3F, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x02, 0x11, 0x15, 0x29, 0x00, 0x3F, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x00, 0x00, 0x2A, 0x3F, 0x00, 0xC8, 0x3C, - 0x7E, 0x1F, 0x79, 0x07, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x11, 0x2A, 0x3F, 0x00, 0xFE, 0x96, 0x8E, 0x3F, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x02, - 0x11, 0x2A, 0x3F, 0x00, 0xF9, 0x36, 0xFF, 0x00, 0x99, 0xFB, 0xDF, 0xFF, 0xD9, -]; -const SECOND_SAMPLING: usize = 34; - -/// An 8 × 8 baseline CMYK JPEG as jpeg-encoder 0.6 writes it at quality 50 with 2 × 2 sampling: -/// its black is sampled 2 × 2 against 1 × 1 for the others (SOF0 at byte 36), a well-formed file -/// of the shape the check is about, in a baseline frame. -const TINY_BASELINE_CMYK: &[u8] = &[ - 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x02, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xEE, 0x00, 0x0E, - 0x41, 0x64, 0x6F, 0x62, 0x65, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xC0, 0x00, 0x14, 0x08, 0x00, 0x08, 0x00, 0x08, 0x04, 0x00, 0x11, - 0x01, 0x01, 0x11, 0x01, 0x02, 0x11, 0x01, 0x03, 0x22, 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x10, 0x0B, 0x0C, 0x0E, 0x0C, 0x0A, 0x10, 0x0E, 0x0D, - 0x0E, 0x12, 0x11, 0x10, 0x13, 0x18, 0x28, 0x1A, 0x18, 0x16, 0x16, 0x18, 0x31, 0x23, 0x25, 0x1D, 0x28, 0x3A, 0x33, 0x3D, 0x3C, 0x39, 0x33, 0x38, - 0x37, 0x40, 0x48, 0x5C, 0x4E, 0x40, 0x44, 0x57, 0x45, 0x37, 0x38, 0x50, 0x6D, 0x51, 0x57, 0x5F, 0x62, 0x67, 0x68, 0x67, 0x3E, 0x4D, 0x71, 0x79, - 0x70, 0x64, 0x78, 0x5C, 0x65, 0x67, 0x63, 0xFF, 0xDB, 0x00, 0x43, 0x01, 0x11, 0x12, 0x12, 0x18, 0x15, 0x18, 0x2F, 0x1A, 0x1A, 0x2F, 0x63, 0x42, - 0x38, 0x42, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, - 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, - 0x63, 0x63, 0x63, 0x63, 0xFF, 0xC4, 0x00, 0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02, - 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, - 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, 0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, 0x72, 0x82, 0x09, - 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, - 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, - 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, - 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, - 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, - 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xC4, 0x00, 0x1F, 0x01, 0x00, 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x11, 0x00, 0x02, 0x01, 0x02, 0x04, 0x04, - 0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, 0x01, 0x02, 0x77, 0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, 0x06, 0x12, 0x41, 0x51, 0x07, - 0x61, 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xA1, 0xB1, 0xC1, 0x09, 0x23, 0x33, 0x52, 0xF0, 0x15, 0x62, 0x72, 0xD1, 0x0A, 0x16, - 0x24, 0x34, 0xE1, 0x25, 0xF1, 0x17, 0x18, 0x19, 0x1A, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, - 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76, - 0x77, 0x78, 0x79, 0x7A, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, - 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, - 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, - 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xDA, 0x00, 0x0E, 0x04, 0x00, 0x11, 0x01, 0x11, 0x02, 0x11, 0x03, 0x00, 0x00, 0x3F, 0x00, 0xEE, 0xA6, 0xAA, 0x2A, - 0xB3, 0x83, 0x95, 0xED, 0x6D, 0x75, 0xBB, 0xF5, 0xB4, 0x97, 0x6D, 0xB6, 0x5A, 0xD9, 0x2B, 0x8D, 0x39, 0x3A, 0xB3, 0x8F, 0x54, 0xFB, 0xCB, 0xAF, - 0xFD, 0xBD, 0x7E, 0x9D, 0xDA, 0xEC, 0x96, 0xB7, 0xE2, 0x95, 0x47, 0x2A, 0x8E, 0x0B, 0x46, 0xB7, 0x77, 0x7A, 0xDD, 0xBE, 0xCD, 0x3F, 0xBD, 0xBF, - 0x2B, 0x6C, 0x0E, 0xC3, 0x6E, 0xE9, 0xB6, 0xCA, 0xE0, 0x07, 0x1B, 0xC9, 0x21, 0x95, 0x4E, 0x72, 0x54, 0x0C, 0x8E, 0x00, 0x62, 0x00, 0x5C, 0x28, - 0x45, 0xEF, 0x51, 0xFF, 0x00, 0x6B, 0xDA, 0xFF, 0x00, 0x76, 0xC7, 0xFF, 0x00, 0x01, 0x27, 0xA1, 0xFF, 0x00, 0xE3, 0xFB, 0xFE, 0xDD, 0x65, 0xFF, - 0x00, 0xD1, 0x31, 0xD6, 0x35, 0x7F, 0xFF, 0xD9, -]; - -fn with_second_sampled(hv: u8) -> Vec { - let mut b = TINY_PROGRESSIVE.to_vec(); - b[SECOND_SAMPLING] = hv; - b -} +// The fixtures live with the header check in `figcraft-format` (`jpeg.rs`), which tests it there. +include!("../../format/src/jpeg_fixtures.rs"); #[test] -fn the_jpeg_as_written_is_not_finer_than_its_first_and_decodes() { - assert_eq!(TINY_PROGRESSIVE[SECOND_SAMPLING], 0x11); - assert!(!images::jpeg_finer_than_first(TINY_PROGRESSIVE)); +fn the_jpeg_as_written_decodes() { let p = images::decode(TINY_PROGRESSIVE).expect("decoded"); assert_eq!((p.width(), p.height()), (8, 8)); - // Chroma sampled coarser than luma (the usual way round) isn't finer either. - assert!(!images::jpeg_finer_than_first(&{ - let mut b = TINY_PROGRESSIVE.to_vec(); - b[SECOND_SAMPLING - 3] = 0x22; - b - })); } /// Without the check this test panics in the decoder (an assertion in zune-jpeg's upsampler). #[test] fn a_progressive_jpeg_sampling_a_component_finer_than_its_first_is_refused_from_its_header() { for hv in [0x22, 0x21, 0x12] { - let b = with_second_sampled(hv); - assert!(images::jpeg_finer_than_first(&b), "second component {hv:#x}"); - assert!(images::decode(&b).is_none(), "second component {hv:#x}"); + assert!(images::decode(&with_second_sampled(hv)).is_none(), "second component {hv:#x}"); } let mut third = TINY_PROGRESSIVE.to_vec(); third[SECOND_SAMPLING + 3] = 0x22; - assert!(images::jpeg_finer_than_first(&third)); assert!(images::decode(&third).is_none()); } #[test] fn baseline_frames_and_other_files_are_left_to_the_decoder() { - // A well-formed baseline frame with its black sampled finer than its cyan: the decoder takes - // it, so it isn't refused from its header. - assert!(!images::jpeg_finer_than_first(TINY_BASELINE_CMYK)); + // A well-formed baseline frame with its black sampled finer than its cyan: the decoder takes it. let p = images::decode(TINY_BASELINE_CMYK).expect("decoded"); assert_eq!((p.width(), p.height()), (8, 8)); - // Not JPEGs, or JPEGs that end before their frame. - let mut png = Vec::new(); - image::RgbaImage::from_pixel(4, 4, image::Rgba([1, 2, 3, 255])).write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); - assert!(!images::jpeg_finer_than_first(&png)); - assert!(!images::jpeg_finer_than_first(&TINY_PROGRESSIVE[..19])); - assert!(!images::jpeg_finer_than_first(&[0xFF, 0xD8])); - assert!(!images::jpeg_finer_than_first(&[])); assert!(images::decode(&TINY_PROGRESSIVE[..19]).is_none()); } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 96301dc3..f44e2296 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-10-29 · **Change:** minor (`.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-10-29 · **Change:** minor (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 @@ -184,6 +184,7 @@ source paint coordinates. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | patch | M8: the progressive JPEG check from #1491 (a frame sampling a component more finely than its first, which the JPEG decoder asserts on, and in the browser a panic aborts the page) moved to `figcraft_format::jpeg` and now also guards the pictures files carry (`thumbnail::decode_thumbnail`: the home screen and recent files), so a crafted `.figcraft`, `.fig` or `.sketch` thumbnail or image file shows no picture instead of crashing. The canvas, exports and SVG/PDF keep it through the renderer. Regression tests in `jpeg_tests` (format) and `jpeg_sampling_tests` (render). Numbers unchanged | | 2026-10-29 | minor | Fonts: the missing-font row adds downloading a missing family the font store has (Google Fonts, Noto) from the Missing fonts window, after the user allows it (#754). Replacing a missing font with another is still missing. Numbers unchanged | | 2026-10-29 | minor | Image key names (#1086): one rule now covers the archive reader, the collab entries and the REST importer's placeholders — 1 to 128 ASCII letters, digits, `-` or `_`. The reader accepts `-` and `_` from now on, so the REST importer's `rest-image-missing` placeholder survives a save and load instead of being dropped as damaged; a placeholder whose `imageRef` can't name an archive member (path text, an over-long ref) or is shaped like a content hash (64 hex digits, which the placeholder's bytes don't hash to) now shares that placeholder instead of being keyed by its raw name. The importers' largest image (256 MiB) is one constant, `figcraft_doc::ImageData::MAX_BYTES`, which live sessions use too. Numbers unchanged | | 2026-10-27 | minor | M6, #1397: damaged generated FigJam shape geometry falls back to usable outer fill or stroke outlines before a rectangle; a surviving sticky author no longer suppresses the outer body-text fallback. Synthetic regressions cover both outlines, missing/invalid/blank bodies, author retention and layer budgets. Numbers unchanged. | diff --git a/docs/roadmap.md b/docs/roadmap.md index a0d3b0b2..d9bb404c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (`.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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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-10-28 · **Change:** minor (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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | patch | M8: the progressive JPEG check from #1491 (a frame sampling a component more finely than its first, which the JPEG decoder asserts on, and in the browser a panic aborts the page) moved to `figcraft_format::jpeg` and now also guards the pictures files carry (`thumbnail::decode_thumbnail`: the home screen and recent files), so a crafted `.figcraft`, `.fig` or `.sketch` thumbnail or image file shows no picture instead of crashing. The canvas, exports and SVG/PDF keep it through the renderer. Regression tests in `jpeg_tests` (format) and `jpeg_sampling_tests` (render). Numbers unchanged | | 2026-10-29 | minor | M6, #1234: `.fig` import resolves style links that name a library style by its asset key and version instead of a guid (the file's own copy of the style), as variable links already were; text in instances of library components kept a stale copy of the style's values and no link. The linked text style beats the copies of its values on the layer and on the override rows below the row that links it, and rows above that row (an outer instance's size, say) still win. Override rows that restate a nested level's overrides (`overrideLevel`) apply at that level, below the instance's own rows, instead of in wire order. Synthetic regression tests in `text_style_tests`. Numbers unchanged | | 2026-10-29 | minor | M6, #1236 (gap 1): imported `.fig` text keeps the line breaks the file saved with it (`derivedTextData`), so a font whose advances differ slightly from the file's (another build of the family, a stand-in) no longer wraps a word early or splits one in a narrow box. The breaks hold only while the box width and everything that moves a break are as saved (characters, fonts, sizes, spacing, case, axes, features, lists, paragraph settings; paints, links and vertical settings don't count), and are given up where a line would come out more than 3% (+1 px) wider than its box; they are kept in `.figcraft`. The line breaker was checked and is unchanged: trailing spaces and the letter spacing after a line's last glyph already stay out of the fit test, box widths aren't rounded and the tolerance is 1e-6 px, so the differences come from the fonts. Synthetic tests in `saved_breaks_tests` and `fig::text_wrap_tests`; not checked on real files. Numbers unchanged | | 2026-10-29 | minor | #754, #1025: the Missing fonts window offers Download for a family the suite's font store has (Google Fonts, Noto: craft-libs' `craft-font-store`, its pinned indexes compiled in). The first Download asks the user to allow downloading fonts (off until then; the setting is the Craft apps' shared one); the files download on a thread of their own, each checked against the index's size and sha256 before it is installed into the shared font folder, which the font scan now reads; the row shows progress or why it failed, and once installed the file's text fits the real font and the family leaves the list. `font.download {family}` and `font.downloads` are commands, but no command turns downloading on; agents install only while the user has it on. Desktop only. No cancel, no turning it off from FigCraft, no substitute picker. Tests: `fonts::download::tests`, `missing_fonts_tests`. Numbers unchanged | From 1c3f72681db26f84c656bdd79a9e6619402e161b Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:14:17 +0100 Subject: [PATCH 14/63] Fix double alpha when binding a translucent variable to an empty fill Fixes #1514 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/styles.rs | 9 ++++++++- crates/engine/src/variables_behaviour_tests.rs | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index 0f3fdef6..b4b337fc 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -1253,7 +1253,14 @@ pub(crate) fn set_paint_color(list: &mut Vec, i: usize, stop: Option p.kind = PaintKind::Solid { color: c }, - (None, None) if i == 0 => list.push(Paint::solid(c)), + (None, None) if i == 0 => { + // A bound colour owns its alpha, so the new paint keeps it out of the opacity. + let mut p = Paint::solid(c); + p.kind = PaintKind::Solid { color: c }; + p.opacity = 1.0; + p.color_alpha = 1.0; + list.push(p); + } (None, _) => {} } } diff --git a/crates/engine/src/variables_behaviour_tests.rs b/crates/engine/src/variables_behaviour_tests.rs index 3fe156f8..7f80a044 100644 --- a/crates/engine/src/variables_behaviour_tests.rs +++ b/crates/engine/src/variables_behaviour_tests.rs @@ -480,3 +480,21 @@ fn a_gesture_or_a_text_style_on_a_bound_field_detaches_it() { assert_eq!(node(&s, t).kind.text().unwrap().characters, "Bye"); assert!(node(&s, t).bound.is_empty()); } + +#[test] +fn a_translucent_variable_bound_to_an_empty_fill_applies_its_alpha_once() { + // #1514: the first paint added by a binding must not take the colour's alpha as opacity too. + let mut s = Session::new(); + run(&mut s, "variable.createCollection", json!({"name": "Theme", "modes": ["Light", "Dark"]})); + run( + &mut s, + "variable.create", + json!({"collection": "Theme", "name": "tint", "type": "color", "values": {"Light": "#FF000080", "Dark": "#00FF0040"}}), + ); + let r = id(run(&mut s, "node.create", json!({"type": "rectangle", "props": {"fills": []}}))); + run(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "tint"})); + assert_eq!(node(&s, r).fills[0].opacity, 1.0); + run(&mut s, "variable.setMode", json!({"collection": "Theme", "mode": "Dark", "ids": [r]})); + assert_eq!(node(&s, r).fills[0].solid_color().unwrap().to_css_hex(), "#00FF0040"); + assert_eq!(node(&s, r).fills[0].opacity, 1.0); +} From e32f8f2e075e1e433bdef770a34030b750edceb3 Mon Sep 17 00:00:00 2001 From: BFlat Date: Thu, 29 Oct 2026 18:14:18 +0700 Subject: [PATCH 15/63] M8: large images on the canvas draw from a smaller copy (#1483) (#1518) * M8: large images on the canvas draw from a smaller copy (#1483) An image shown smaller than its pixels draws on the canvas from a copy scaled down to 4096 or 8192 px on its longer side, cached beside the image in the same budget. Two 16000 x 10000 images (640 MB each) evicted each other on every frame that drew both; as copies they take 80 MB together zoomed out. Moving one over the other: about 2.1 s a frame before, 11 ms at fit and 15 ms at 30% after. Exports, thumbnails and views at 100% and closer draw from the image itself. Tests in large_image_tests; timings in large_image_bench_tests (ignored). * M8: drop the ignored large-image benchmark; its numbers are in the PR (#1483) --- ROADMAP.md | 3 +- crates/render/src/draw.rs | 5 +- crates/render/src/image_budget_tests.rs | 4 +- crates/render/src/images.rs | 55 ++++++++++-- crates/render/src/large_image_tests.rs | 115 ++++++++++++++++++++++++ crates/render/src/lib.rs | 9 +- crates/render/src/lod.rs | 3 +- crates/render/src/lod_tests.rs | 1 + crates/render/src/paint.rs | 26 ++++-- crates/render/src/pattern.rs | 1 + docs/hardware-parity.md | 5 +- docs/roadmap.md | 3 +- 12 files changed, 206 insertions(+), 24 deletions(-) create mode 100644 crates/render/src/large_image_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 1abbd777..907afe3f 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-29 · **Change:** minor (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-10-29 · **Change:** minor (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 @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Large images on the canvas (#1483): an image shown smaller than its pixels draws on the canvas from a copy scaled down to 4096 or 8192 px on its longer side, the smallest that holds what the screen shows (`figcraft_render::images::shown`), cached beside the image itself in the same 512 MB budget. Two 16 000 × 10 000 images took 640 MB each and evicted each other on every frame that drew both; as copies they take 80 MB together zoomed out and 336 MB at 8192. Moving one over the other went from about 2.1 s a frame to 11 ms zoomed to fit and 15 ms at 30% in the app (debug build). Closer than that, and for tiles, the canvas draws from the image itself, so 100% and closer stay sharp; a view of both at 100% still takes about 2 s a frame, and an image with shadows still decodes in full for them. Exports, thumbnails and every other render are unchanged. Tests: `large_image_tests`. Numbers unchanged | | 2026-10-29 | patch | M8: the progressive JPEG check from #1491 (a frame sampling a component more finely than its first, which the JPEG decoder asserts on, and in the browser a panic aborts the page) moved to `figcraft_format::jpeg` and now also guards the pictures files carry (`thumbnail::decode_thumbnail`: the home screen and recent files), so a crafted `.figcraft`, `.fig` or `.sketch` thumbnail or image file shows no picture instead of crashing. The canvas, exports and SVG/PDF keep it through the renderer. Regression tests in `jpeg_tests` (format) and `jpeg_sampling_tests` (render). Numbers unchanged | | 2026-10-29 | minor | M6, #1234: `.fig` import resolves style links that name a library style by its asset key and version instead of a guid (the file's own copy of the style), as variable links already were; text in instances of library components kept a stale copy of the style's values and no link. The linked text style beats the copies of its values on the layer and on the override rows below the row that links it, and rows above that row (an outer instance's size, say) still win. Override rows that restate a nested level's overrides (`overrideLevel`) apply at that level, below the instance's own rows, instead of in wire order. Synthetic regression tests in `text_style_tests`. Numbers unchanged | | 2026-10-29 | minor | M6, #1236 (gap 1): imported `.fig` text keeps the line breaks the file saved with it (`derivedTextData`), so a font whose advances differ slightly from the file's (another build of the family, a stand-in) no longer wraps a word early or splits one in a narrow box. The breaks hold only while the box width and everything that moves a break are as saved (characters, fonts, sizes, spacing, case, axes, features, lists, paragraph settings; paints, links and vertical settings don't count), and are given up where a line would come out more than 3% (+1 px) wider than its box; they are kept in `.figcraft`. The line breaker was checked and is unchanged: trailing spaces and the letter spacing after a line's last glyph already stay out of the fit test, box widths aren't rounded and the tolerance is 1e-6 px, so the differences come from the fonts. Synthetic tests in `saved_breaks_tests` and `fig::text_wrap_tests`; not checked on real files. Numbers unchanged | diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 679f6287..34bb5645 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -30,6 +30,9 @@ pub struct Ctx<'a, 'd> { pub skip: &'a [NodeId], /// Approximate what's too small to see in detail (canvas previews; see [`crate::lod`]). pub lod: bool, + /// A canvas preview at any zoom ([`crate::Options::lod`]): large images shown smaller than + /// their pixels draw from smaller copies (see [`crate::images::shown`]). + pub preview: bool, pub magnified_image_pixels: bool, /// The surfaces under the one being drawn into, when it is an isolated layer (for backdrops). pub under: Option<&'a Under<'a>>, @@ -130,7 +133,7 @@ fn pixels_when_magnified(prep: Prepared, device_scale: f64) -> Prepared { fn fill_with(pix: &mut Pixmap, path: &tiny_skia::Path, paints: &[Paint], w: f64, h: f64, xf: Affine, rule: FillRule, ctx: &Ctx, mask: Option<&Mask>) { let scale = xf.determinant().abs().sqrt(); for p in paints { - let Some(prep) = prepare_at(p, w, h, scale, ctx.doc, ctx.time) else { continue }; + let Some(prep) = prepare_at(p, w, h, scale, ctx.doc, ctx.time, ctx.preview) else { continue }; let prep = if ctx.magnified_image_pixels && matches!(p.kind, PaintKind::Image(_)) { pixels_when_magnified(prep, scale) } else { prep }; let paint = tiny_skia::Paint { shader: prep.shader(), anti_alias: true, blend_mode: ts_blend(p.blend), ..Default::default() }; if p.blend == BlendMode::PlusDarker { diff --git a/crates/render/src/image_budget_tests.rs b/crates/render/src/image_budget_tests.rs index 9a51f405..2171927a 100644 --- a/crates/render/src/image_budget_tests.rs +++ b/crates/render/src/image_budget_tests.rs @@ -82,7 +82,7 @@ fn an_image_past_the_side_limit_is_drawn_downscaled() { } fn key(name: &str) -> images::Key { - (name.to_string(), [0; 7], ColorProfile::Srgb) + (name.to_string(), [0; 7], ColorProfile::Srgb, 0) } fn pixmap(side: u32) -> Arc { @@ -132,7 +132,7 @@ fn filter_and_profile_variants_count_against_one_budget() { let mut c = images::PixmapCache::default(); for (i, profile) in [ColorProfile::Srgb, ColorProfile::DisplayP3].into_iter().enumerate() { for f in 0..4 { - c.insert(("img".to_string(), [f, 0, 0, 0, 0, 0, i as i32], profile), pixmap(10), 1000); + c.insert(("img".to_string(), [f, 0, 0, 0, 0, 0, i as i32], profile, 0), pixmap(10), 1000); assert!(c.bytes() <= 1000, "{} bytes held", c.bytes()); } } diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index ba76846b..a4d8ead0 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -1,6 +1,10 @@ -//! Decoded image cache (content hash + filters + colour profile → premultiplied pixmap). Images -//! are converted from their own colour profile (see [`crate::image_profile`]; sRGB when they have -//! none) into the document's space, once per image and profile. +//! Decoded image cache (content hash + filters + colour profile + largest side → premultiplied +//! pixmap). Images are converted from their own colour profile (see [`crate::image_profile`]; sRGB +//! when they have none) into the document's space, once per image and profile. +//! +//! The canvas draws a large image shown smaller than its pixels from a copy scaled down to one of +//! [`DISPLAY_SIDES`] (see [`shown`]), so several of them stay decoded within the cache's budget +//! (#1483); exports and every other render draw from the image itself ([`get`]). //! //! Animated GIFs keep their original bytes like any image; their first frame is the image //! everywhere (canvas, exports). The presenter plays them: [`frame_at`] decodes every frame on @@ -13,7 +17,8 @@ use figcraft_color::{ColorProfile, ImageFilters, PaintKind}; use figcraft_doc::{Document, ImageData, NodeId}; use tiny_skia::Pixmap; -pub(crate) type Key = (String, [i32; 7], ColorProfile); +/// Image, filters, colour profile and the largest side it was decoded at. +pub(crate) type Key = (String, [i32; 7], ColorProfile, u32); /// The largest side of a decoded image: larger images are scaled down to fit. const MAX_SIDE: u32 = 16_384; @@ -22,9 +27,13 @@ const MAX_SIDE: u32 = 16_384; const MAX_DECODED_BYTES: u64 = 512 << 20; /// The most bytes of pixels each decoded-image cache holds (one image larger than this is held /// alone). -const MAX_CACHED_BYTES: u64 = 512 << 20; +pub(crate) const MAX_CACHED_BYTES: u64 = 512 << 20; /// The most entries each decoded-image cache holds. const MAX_CACHED_IMAGES: usize = 256; +/// The largest sides of the copies the canvas draws larger images from while they show smaller +/// (device pixels): two 16 000 × 10 000 images take 640 MB each in full, past the cache's budget +/// together, and 42 MB each at 4096 (168 MB at 8192). +pub const DISPLAY_SIDES: [u32; 2] = [4096, 8192]; /// Decoded pixmaps by image, filters and profile, holding at most a byte budget of pixels: adding /// one past the budget (or past [`MAX_CACHED_IMAGES`]) drops the least recently used first, so @@ -73,6 +82,15 @@ impl PixmapCache { } } +/// The sides image `hash` is decoded at in the cache (any filters and profile), smallest first. +#[cfg(test)] +pub(crate) fn cached_sides(hash: &str) -> Vec { + let c = cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let mut sides: Vec = c.map.keys().filter(|k| k.0 == hash).map(|k| k.3).collect(); + sides.sort_unstable(); + sides +} + fn cache() -> &'static Mutex { static C: OnceLock> = OnceLock::new(); C.get_or_init(|| Mutex::new(PixmapCache::default())) @@ -182,11 +200,30 @@ fn premultiplied(rgba: &image::RgbaImage) -> Option { /// The decoded (and filtered) image for `hash` in the colour space `profile`, cached. pub fn get(hash: &str, data: Option<&ImageData>, filters: &ImageFilters, profile: ColorProfile) -> Option> { - let key = (hash.to_string(), fkey(filters), profile); + get_within(hash, data, filters, profile, MAX_SIDE) +} + +/// [`get`] for the canvas, where the image shows `side` device pixels on its longer side: an image +/// larger than one of [`DISPLAY_SIDES`] that holds `side` comes scaled down to that side (cached +/// apart), else as [`get`] gives it. The image's size is the one `data` records (none: the image). +pub fn shown(hash: &str, data: Option<&ImageData>, filters: &ImageFilters, profile: ColorProfile, side: f64) -> Option> { + get_within(hash, data, filters, profile, display_side(data.map_or(0, |d| d.width.max(d.height)), side)) +} + +/// The largest side to decode an image `long` pixels on its longer side at, to show `side` device +/// pixels long: the smallest of [`DISPLAY_SIDES`] that holds `side` and is smaller than the image, +/// else the image's own ([`MAX_SIDE`]). +pub(crate) fn display_side(long: u32, side: f64) -> u32 { + DISPLAY_SIDES.into_iter().find(|s| f64::from(*s) >= side && *s < long).unwrap_or(MAX_SIDE) +} + +/// [`get`] scaled down to at most `max_side` wide and tall. +fn get_within(hash: &str, data: Option<&ImageData>, filters: &ImageFilters, profile: ColorProfile, max_side: u32) -> Option> { + let key = (hash.to_string(), fkey(filters), profile, max_side); if let Some(p) = cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) { return Some(p); } - let (mut p, srgb) = decode_in(&data?.bytes, profile)?; + let (mut p, srgb) = decode_within(&data?.bytes, profile, max_side, MAX_DECODED_BYTES)?; // Images with their own profile are in `profile` already (sRGB → sRGB is the identity). finish(&mut p, filters, if srgb { profile } else { ColorProfile::Srgb }); let p = Arc::new(p); @@ -402,9 +439,9 @@ fn padded_cache() -> &'static Mutex { C.get_or_init(|| Mutex::new(PixmapCache::default())) } -/// `pm` with a transparent 1-pixel border (cached). +/// `pm` with a transparent 1-pixel border (cached; a smaller copy of the image apart). pub fn padded(hash: &str, filters: &ImageFilters, profile: ColorProfile, pm: &Pixmap) -> Option> { - let key = (hash.to_string(), fkey(filters), profile); + let key = (hash.to_string(), fkey(filters), profile, pm.width().max(pm.height())); if let Some(p) = padded_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) { return Some(p); } diff --git a/crates/render/src/large_image_tests.rs b/crates/render/src/large_image_tests.rs new file mode 100644 index 00000000..be3de57f --- /dev/null +++ b/crates/render/src/large_image_tests.rs @@ -0,0 +1,115 @@ +//! Large images on the canvas (#1483): a large image shown smaller than its pixels is drawn from a +//! copy no larger than it shows, so two of them stay decoded together, while exports and close-up +//! views draw from the image itself. + +use std::sync::Arc; + +use figcraft_color::{ImageScaleMode, Paint}; +use figcraft_doc::{Document, ImageData, NodeId, NodeKind}; +use tiny_skia::Pixmap; + +use crate::images::{self, DISPLAY_SIDES, MAX_CACHED_BYTES}; +use crate::{Options, View, render_view}; + +const W: u32 = 6000; +const H: u32 = 8; + +/// A `W` × `H` PNG of one-pixel black and white columns, black first unless `phase` is odd. +fn stripes(phase: u32) -> Vec { + let img = image::RgbaImage::from_fn(W, H, |x, _| image::Rgba(if (x + phase).is_multiple_of(2) { [0, 0, 0, 255] } else { [255, 255, 255, 255] })); + let mut out = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out +} + +/// Two `W` × `H` images at their own size, one under the other (keys starting with `tag`: the +/// cache is shared by every test). +fn doc(tag: &str) -> (Document, NodeId, [String; 2]) { + let mut d = Document::new("large images"); + let page = d.first_page().unwrap(); + let keys = [format!("{tag}-a"), format!("{tag}-b")]; + for (i, key) in (0u32..).zip(&keys) { + d.images.insert(key.clone(), ImageData { bytes: Arc::new(stripes(i)), width: W, height: H }); + let r = d.create(page, NodeKind::Rectangle, "Image", 0.0, f64::from(i * 2 * H), f64::from(W), f64::from(H)).unwrap(); + d.get_mut(r).unwrap().fills = vec![Paint::image(key, ImageScaleMode::Fill)]; + } + (d, page, keys) +} + +fn canvas() -> Options { + Options { lod: true, magnified_image_pixels: true, ..Default::default() } +} + +/// The first `W` units of both rows at `scale`. +fn view(scale: f64) -> View { + View { origin: (0.0, 0.0), scale, width: (f64::from(W) * scale).ceil() as u32, height: (f64::from(4 * H) * scale).ceil() as u32 } +} + +/// The canvas's copy of image `key` of `d` shown `side` device pixels long. +fn shown(d: &Document, key: &str, side: f64) -> Arc { + images::shown(key, d.images.get(key), &Default::default(), d.color_profile, side).unwrap() +} + +/// Whether row `y` of `p` shows one-pixel columns, black at even `x` when `black_first`, `each` +/// device pixels wide. +fn sharp_stripes(p: &Pixmap, y: u32, black_first: bool, each: u32) -> bool { + (0..200).chain(4000..4200).all(|x| { + let red = p.pixel(x, y).unwrap().red(); + let black = (x / each) % 2 == u32::from(!black_first); + if black { red < 8 } else { red > 247 } + }) +} + +#[test] +fn zoomed_out_the_canvas_draws_both_images_from_copies_that_stay_decoded() { + let (d, page, keys) = doc("zoomed-out"); + // At 25% each shows 1500 px long: drawn from 4096 px copies, not decoded in full. + render_view(&d, page, &view(0.25), &canvas()).unwrap(); + let first = keys.each_ref().map(|k| shown(&d, k, 1500.0)); + for (k, p) in keys.iter().zip(&first) { + assert_eq!((p.width(), p.height()), (DISPLAY_SIDES[0], 5), "{k}"); + assert_eq!(images::cached_sides(k), vec![DISPLAY_SIDES[0]], "{k}: only its copy is decoded"); + } + // Drawn again (a drag, a pan), both are still the copies decoded the first time. + render_view(&d, page, &view(0.25), &canvas()).unwrap(); + for (k, p) in keys.iter().zip(&first) { + assert!(Arc::ptr_eq(p, &shown(&d, k, 1500.0)), "{k} was decoded again"); + } +} + +#[test] +fn exports_and_close_ups_draw_from_the_image_itself() { + let (d, page, keys) = doc("close-up"); + // An export at 1x, and the canvas at 100% and 200%, show every pixel column. + let export = render_view(&d, page, &view(1.0), &Options::default()).unwrap(); + assert!(sharp_stripes(&export, 4, true, 1) && sharp_stripes(&export, 2 * H + 4, false, 1)); + let at_100 = render_view(&d, page, &view(1.0), &canvas()).unwrap(); + assert!(sharp_stripes(&at_100, 4, true, 1) && sharp_stripes(&at_100, 2 * H + 4, false, 1)); + let at_200 = render_view(&d, page, &view(2.0), &canvas()).unwrap(); + assert!(sharp_stripes(&at_200, 8, true, 2)); + assert_eq!(images::cached_sides(&keys[0]), vec![16_384], "the image itself, as exports draw it"); +} + +#[test] +fn the_copy_is_the_smallest_that_holds_what_shows() { + let side = |long, shown| images::display_side(long, shown); + assert_eq!(side(6000, 1500.0), 4096); + assert_eq!(side(16_000, 4096.0), 4096); + assert_eq!(side(16_000, 4097.0), 8192); + // Shown at more than the largest copy, or an image no larger than the copy: the image itself. + assert_eq!(side(16_000, 9000.0), 16_384); + assert_eq!(side(6000, 5000.0), 16_384); + assert_eq!(side(4096, 100.0), 16_384); + // An unknown size or a meaningless scale: the image itself. + assert_eq!(side(0, 100.0), 16_384); + assert_eq!(side(16_000, f64::NAN), 16_384); +} + +#[test] +fn two_16k_images_fit_the_cache_as_copies_but_not_in_full() { + // 16 000 × 10 000 (#1483's size class): 640 MB each in full, 42 MB at 4096 and 168 MB at 8192. + let bytes = |w: u64, h: u64| w * h * 4; + assert!(2 * bytes(16_000, 10_000) > MAX_CACHED_BYTES); + assert!(2 * bytes(8192, 5120) <= MAX_CACHED_BYTES); + assert!(2 * bytes(4096, 2560) <= MAX_CACHED_BYTES); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 321c467e..a357033d 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -78,8 +78,9 @@ pub struct Options { /// Draw only this layer of the page, where it is (Dev Mode's focus view). pub solo: Option, /// A preview (the canvas): when zoomed out, approximate what's too small on screen to see in - /// detail, see [`lod`]. Off for everything that must be exact (exports, thumbnails); no - /// effect in outline mode. + /// detail, see [`lod`]; at any zoom, draw a large image shown smaller than its pixels from a + /// smaller copy (see [`images::shown`]). Off for everything that must be exact (exports, + /// thumbnails); no effect in outline mode. pub lod: bool, pub magnified_image_pixels: bool, /// A dot grid over the background, under the layers (a board's canvas; drawn only where the @@ -209,6 +210,7 @@ fn render_one(doc: &Document, page: NodeId, view: &View, opts: &Options) -> Opti outlined: &opts.outlined, skip: &opts.skip, lod, + preview: opts.lod, magnified_image_pixels: opts.magnified_image_pixels, under: None, time: 0.0, @@ -243,6 +245,7 @@ fn nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap, tim outlined: &[], skip: &[], lod: false, + preview: false, magnified_image_pixels: false, under: None, time, @@ -496,6 +499,8 @@ mod image_shadow_tests; #[cfg(test)] mod jpeg_sampling_tests; #[cfg(test)] +mod large_image_tests; +#[cfg(test)] mod lod_tests; #[cfg(test)] mod magnified_image_tests; diff --git a/crates/render/src/lod.rs b/crates/render/src/lod.rs index e330568f..aaa77b98 100644 --- a/crates/render/src/lod.rs +++ b/crates/render/src/lod.rs @@ -12,7 +12,8 @@ //! //! Only views at [`MAX_SCALE`] or below are approximated: 1:1 and closer previews are exact, and //! so are exports, thumbnails and every other render. Whatever LOD can't approximate faithfully -//! (image paints, masks) takes the exact path at any scale. +//! (image paints, masks) takes the exact path at any scale, except that previews draw a large image +//! shown smaller than its pixels from a smaller copy ([`crate::images::shown`]). use figcraft_color::{BlendMode, Paint, PaintKind}; use figcraft_doc::{Document, ListStyle, Node, NodeKind, StrokeAlign, TextAutoResize, TextData}; diff --git a/crates/render/src/lod_tests.rs b/crates/render/src/lod_tests.rs index f2c83dba..d7cd983c 100644 --- a/crates/render/src/lod_tests.rs +++ b/crates/render/src/lod_tests.rs @@ -176,6 +176,7 @@ fn exports_never_use_lod() { outlined: &[], skip: &[], lod: true, + preview: false, magnified_image_pixels: false, under: None, time: 0.0, diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index b83aa0cb..a6c05948 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use figcraft_color::{BlendMode, Color, Gradient, GradientKind, ImagePaint, ImageScaleMode, Paint, PaintKind}; -use figcraft_doc::Document; +use figcraft_doc::{Document, ImageData}; use tiny_skia::{FilterQuality, GradientStop, Pixmap, Point, Shader, SpreadMode, Transform}; pub fn ts_color(c: Color) -> tiny_skia::Color { @@ -95,11 +95,12 @@ fn diamond_pixmap(g: &Gradient, opacity: f32) -> Option> { /// (the resolution pattern tiles are drawn at). Returns `None` when the paint draws nothing /// (invisible, fully transparent, missing image or pattern source). pub fn prepare(paint: &Paint, w: f64, h: f64, scale: f64, doc: &Document) -> Option { - prepare_at(paint, w, h, scale, doc, 0.0) + prepare_at(paint, w, h, scale, doc, 0.0, false) } -/// [`prepare`] `time` seconds into animated GIFs (0: their first frame). -pub fn prepare_at(paint: &Paint, w: f64, h: f64, scale: f64, doc: &Document, time: f64) -> Option { +/// [`prepare`] `time` seconds into animated GIFs (0: their first frame); on the canvas (`preview`) +/// a large image shown smaller than its pixels draws from a smaller copy (see [`still`]). +pub fn prepare_at(paint: &Paint, w: f64, h: f64, scale: f64, doc: &Document, time: f64, preview: bool) -> Option { if !paint.visible || paint.opacity <= 0.0 { return None; } @@ -140,7 +141,7 @@ pub fn prepare_at(paint: &Paint, w: f64, h: f64, scale: f64, doc: &Document, tim let moving = crate::images::frame_at(&img.image, data, &img.filters, doc.color_profile, time); let pm = match &moving { Some(f) => f.clone(), - None => crate::images::get(&img.image, data, &img.filters, doc.color_profile)?, + None => still(img, data, w, h, scale, doc, preview)?, }; let transform = image_transform(img, f64::from(pm.width()), f64::from(pm.height()), w, h); if img.scale_mode == ImageScaleMode::Tile { @@ -164,8 +165,23 @@ pub fn prepare_at(paint: &Paint, w: f64, h: f64, scale: f64, doc: &Document, tim } } +/// The pixmap image paint `img` draws in a `w` × `h` box at `scale` device pixels per unit: on the +/// canvas (`preview`) a copy no larger than the image shows ([`crate::images::shown`]), else the +/// image itself. Tiles keep the image: their size is in its pixels. +fn still(img: &ImagePaint, data: Option<&ImageData>, w: f64, h: f64, scale: f64, doc: &Document, preview: bool) -> Option> { + let (iw, ih) = data.map_or((0, 0), |d| (d.width, d.height)); + if preview && img.scale_mode != ImageScaleMode::Tile && iw > 0 && ih > 0 { + let t = image_transform(img, f64::from(iw), f64::from(ih), w, h); + let side = scale * f64::from(t.sx * t.sy - t.kx * t.ky).abs().sqrt() * f64::from(iw.max(ih)); + return crate::images::shown(&img.image, data, &img.filters, doc.color_profile, side); + } + crate::images::get(&img.image, data, &img.filters, doc.color_profile) +} + /// Whether image paint `img` paints its whole `w` × `h` box opaque: an image without transparent /// pixels that covers the box (or tiles it). +/// shortcut: always looks at the image itself (exact, also on the canvas), so a large image with +/// shadows still decodes in full there; pass the canvas scale here if that turns up (#1483). pub(crate) fn fills_box_opaque(doc: &Document, img: &ImagePaint, w: f64, h: f64) -> bool { let Some(pm) = crate::images::get(&img.image, doc.images.get(&img.image), &img.filters, doc.color_profile) else { return false }; (img.scale_mode == ImageScaleMode::Tile || covers_box(img, f64::from(pm.width()), f64::from(pm.height()), w, h)) diff --git a/crates/render/src/pattern.rs b/crates/render/src/pattern.rs index efa23e7b..172638ce 100644 --- a/crates/render/src/pattern.rs +++ b/crates/render/src/pattern.rs @@ -55,6 +55,7 @@ pub fn cell_pixmap(doc: &Document, pt: &PatternPaint, w: f64, h: f64, scale: f64 outlined: &[], skip: &[], lod: false, + preview: false, magnified_image_pixels: false, under: None, time, diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 20ceb2fb..76da477d 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware and platform parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7) How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out @@ -12,7 +12,7 @@ since 2025). FigCraft renders the canvas on the CPU and presents it through wgpu | Feature | Figma | FigCraft (macOS / Windows / Linux / web) | Hours | |---|---|---|---| -| Canvas rendering | GPU | CPU (tiny-skia), banded across cores, retained (re-renders only what an edit or pan touches), levels of detail below 50% zoom; when renders from scratch are slow, a new zoom shows the whole view coarsely first, then refines it (#312); shown through wgpu (WebGPU or WebGL2 in the browser) | 30–60 for a GPU path | +| Canvas rendering | GPU | CPU (tiny-skia), banded across cores, retained (re-renders only what an edit or pan touches), levels of detail below 50% zoom, large images drawn from a copy no larger than they show (#1483); when renders from scratch are slow, a new zoom shows the whole view coarsely first, then refines it (#312); shown through wgpu (WebGPU or WebGL2 in the browser) | 30–60 for a GPU path | | Effects (blurs, glass, noise) | GPU | CPU | in the above | | HiDPI / Retina | yes | yes (all); a canvas image rendered at the screen's density is placed on whole device pixels, so fractional pans, zooms and display scales don't blur it (#359; geometry tests, the snapping not judged by eye) | — | | Wide-gamut display (Display P3) | yes, on capable displays | colours stored and exported in P3; the screen shows sRGB: eframe/egui-wgpu 0.37 configure an 8-bit sRGB surface with no colour space option, although wgpu 30 can present Display P3 or extended sRGB on Metal, DX12, Vulkan and WebGPU (see gaps #18). The colour picker warns about colours outside sRGB and View ▸ Soft-proof sRGB stripes them on the canvas | 2–5 | @@ -50,6 +50,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Large images on the canvas (#1483): an image shown smaller than its pixels draws on the canvas from a copy scaled down to 4096 or 8192 px on its longer side, the smallest that holds what the screen shows (`figcraft_render::images::shown`), cached beside the image itself in the same 512 MB budget. Two 16 000 × 10 000 images took 640 MB each and evicted each other on every frame that drew both; as copies they take 80 MB together zoomed out and 336 MB at 8192. Moving one over the other went from about 2.1 s a frame to 11 ms zoomed to fit and 15 ms at 30% in the app (debug build). Closer than that, and for tiles, the canvas draws from the image itself, so 100% and closer stay sharp; a view of both at 100% still takes about 2 s a frame, and an image with shadows still decodes in full for them. Exports, thumbnails and every other render are unchanged. Tests: `large_image_tests`. Numbers unchanged | | 2026-10-29 | minor | macOS quit confirmation (#857): winit's default Quit menu item (⌘Q) ended the process through AppKit's `terminate:` at once, so a dirty app died without asking anything. The app now installs the craft-libs app menu (craft-menu): its Quit sends the main window a close request — the same path as its close button — so the unsaved-changes question asks before anything is lost (`windows_tests::a_quit_with_unsaved_changes_waits_for_the_answer`; checked in the app on macOS). Like the close button it closes the main window only, so with several windows open each quit closes one. A second quit before the window draws again (two quits while it is minimised, when only the app's logic runs) counts once: before, it could quit the app over another window's unsaved changes (`windows_tests::quitting_twice_before_the_window_draws_closes_it_once`). Numbers unchanged | | 2026-10-25 | minor | egui/eframe 0.37 dependency migration: the workspace pins the egui family at 0.37, and the wide-gamut and several-windows rows now cite 0.37. The limitations are unchanged: egui-wgpu 0.37 offers no surface colour-space option, so the screen still shows sRGB; eframe 0.37 still gives only the main window an AccessKit adapter, so files stay tabs while a screen reader runs. Numbers unchanged | | 2026-10-25 | minor | Hidden or unknown-visibility screenshots request a hidden paint and expire at their 60-second admission deadline if unfinished; injected pointer/key/text input waits for a real UI frame. Browser and Wayland hidden-paint behavior remain unqualified. Numbers unchanged. | diff --git a/docs/roadmap.md b/docs/roadmap.md index d9bb404c..73bc16de 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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-10-28 · **Change:** minor (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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Large images on the canvas (#1483): an image shown smaller than its pixels draws on the canvas from a copy scaled down to 4096 or 8192 px on its longer side, the smallest that holds what the screen shows (`figcraft_render::images::shown`), cached beside the image itself in the same 512 MB budget. Two 16 000 × 10 000 images took 640 MB each and evicted each other on every frame that drew both; as copies they take 80 MB together zoomed out and 336 MB at 8192. Moving one over the other went from about 2.1 s a frame to 11 ms zoomed to fit and 15 ms at 30% in the app (debug build). Closer than that, and for tiles, the canvas draws from the image itself, so 100% and closer stay sharp; a view of both at 100% still takes about 2 s a frame, and an image with shadows still decodes in full for them. Exports, thumbnails and every other render are unchanged. Tests: `large_image_tests`. Numbers unchanged | | 2026-10-29 | patch | M8: the progressive JPEG check from #1491 (a frame sampling a component more finely than its first, which the JPEG decoder asserts on, and in the browser a panic aborts the page) moved to `figcraft_format::jpeg` and now also guards the pictures files carry (`thumbnail::decode_thumbnail`: the home screen and recent files), so a crafted `.figcraft`, `.fig` or `.sketch` thumbnail or image file shows no picture instead of crashing. The canvas, exports and SVG/PDF keep it through the renderer. Regression tests in `jpeg_tests` (format) and `jpeg_sampling_tests` (render). Numbers unchanged | | 2026-10-29 | minor | M6, #1234: `.fig` import resolves style links that name a library style by its asset key and version instead of a guid (the file's own copy of the style), as variable links already were; text in instances of library components kept a stale copy of the style's values and no link. The linked text style beats the copies of its values on the layer and on the override rows below the row that links it, and rows above that row (an outer instance's size, say) still win. Override rows that restate a nested level's overrides (`overrideLevel`) apply at that level, below the instance's own rows, instead of in wire order. Synthetic regression tests in `text_style_tests`. Numbers unchanged | | 2026-10-29 | minor | M6, #1236 (gap 1): imported `.fig` text keeps the line breaks the file saved with it (`derivedTextData`), so a font whose advances differ slightly from the file's (another build of the family, a stand-in) no longer wraps a word early or splits one in a narrow box. The breaks hold only while the box width and everything that moves a break are as saved (characters, fonts, sizes, spacing, case, axes, features, lists, paragraph settings; paints, links and vertical settings don't count), and are given up where a line would come out more than 3% (+1 px) wider than its box; they are kept in `.figcraft`. The line breaker was checked and is unchanged: trailing spaces and the letter spacing after a line's last glyph already stay out of the fit test, box widths aren't rounded and the tolerance is 1e-6 px, so the differences come from the fonts. Synthetic tests in `saved_breaks_tests` and `fig::text_wrap_tests`; not checked on real files. Numbers unchanged | From d4e6ad5d81cccf9957f609248666229b3bcf9f9e Mon Sep 17 00:00:00 2001 From: BFlat Date: Thu, 29 Oct 2026 18:29:48 +0700 Subject: [PATCH 16/63] M12: the connector tool previews the connector it will make, ends snapping to attach points (#1387) (#1526) --- ROADMAP.md | 3 +- crates/engine/src/board_connector_tests.rs | 73 ++++++++++++ crates/engine/src/board_tools_tests.rs | 2 +- crates/engine/src/boards/board/connectors.rs | 41 +++++-- crates/engine/src/boards/board/tools.rs | 114 +++++++++++++------ crates/engine/src/canvas/tools.rs | 3 + crates/engine/src/canvas/tools/pointer.rs | 3 + crates/ui-egui/src/canvas/overlays.rs | 16 ++- docs/boards-parity.md | 5 +- docs/roadmap.md | 3 +- 10 files changed, 213 insertions(+), 50 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 907afe3f..46d1ffe0 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-29 · **Change:** minor (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-10-29 · **Change:** minor (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 @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M12, #1387: while a connector is dragged out with the connector tool, the canvas shows the connector itself as it will be made (its straight, elbow or curved route, line, weight and arrowhead, built by the same code as `board.connector`), not a thin line; the object under the pointer is outlined with its four attach points (the middles of its sides), the one the end goes to ringed; within 10 px of one the end keeps that side (`fromSide`/`toSide`, and `side` when re-attaching). Hovering with the tool shows the attach points too. Escape drops the preview; releasing makes the connector in one undo step. Tests: `board_connector_tests`. Numbers unchanged | | 2026-10-29 | minor | Large images on the canvas (#1483): an image shown smaller than its pixels draws on the canvas from a copy scaled down to 4096 or 8192 px on its longer side, the smallest that holds what the screen shows (`figcraft_render::images::shown`), cached beside the image itself in the same 512 MB budget. Two 16 000 × 10 000 images took 640 MB each and evicted each other on every frame that drew both; as copies they take 80 MB together zoomed out and 336 MB at 8192. Moving one over the other went from about 2.1 s a frame to 11 ms zoomed to fit and 15 ms at 30% in the app (debug build). Closer than that, and for tiles, the canvas draws from the image itself, so 100% and closer stay sharp; a view of both at 100% still takes about 2 s a frame, and an image with shadows still decodes in full for them. Exports, thumbnails and every other render are unchanged. Tests: `large_image_tests`. Numbers unchanged | | 2026-10-29 | patch | M8: the progressive JPEG check from #1491 (a frame sampling a component more finely than its first, which the JPEG decoder asserts on, and in the browser a panic aborts the page) moved to `figcraft_format::jpeg` and now also guards the pictures files carry (`thumbnail::decode_thumbnail`: the home screen and recent files), so a crafted `.figcraft`, `.fig` or `.sketch` thumbnail or image file shows no picture instead of crashing. The canvas, exports and SVG/PDF keep it through the renderer. Regression tests in `jpeg_tests` (format) and `jpeg_sampling_tests` (render). Numbers unchanged | | 2026-10-29 | minor | M6, #1234: `.fig` import resolves style links that name a library style by its asset key and version instead of a guid (the file's own copy of the style), as variable links already were; text in instances of library components kept a stale copy of the style's values and no link. The linked text style beats the copies of its values on the layer and on the override rows below the row that links it, and rows above that row (an outer instance's size, say) still win. Override rows that restate a nested level's overrides (`overrideLevel`) apply at that level, below the instance's own rows, instead of in wire order. Synthetic regression tests in `text_style_tests`. Numbers unchanged | diff --git a/crates/engine/src/board_connector_tests.rs b/crates/engine/src/board_connector_tests.rs index cbb2070e..6e56af73 100644 --- a/crates/engine/src/board_connector_tests.rs +++ b/crates/engine/src/board_connector_tests.rs @@ -177,3 +177,76 @@ fn reattach_survives_junk() { assert!(matches!(node(&s, k).board, Some(BoardItem::Connector(_)))); assert!(drawn(&s, k).iter().all(|p| p.x.is_finite() && p.y.is_finite())); } + +/// The connector on the page, if any (the one being drawn shows in the document). +fn the_connector(s: &Session) -> Option { + s.doc().unwrap().nodes().find(|n| matches!(n.board, Some(BoardItem::Connector(_)))).cloned() +} + +#[test] +fn the_connector_being_drawn_is_the_one_made() { + for style in ["straight", "elbow", "curved"] { + for to in [Point::new(350.0, 250.0), Point::new(700.0, 600.0)] { + let mut s = board(); + let mut shape = |x: f64, y: f64| id(&s.execute("board.shape", &json!({"x": x, "y": y, "width": 100, "height": 100})).unwrap(), "id"); + let (_, b) = (shape(0.0, 0.0), shape(300.0, 200.0)); + s.execute("tool.connector", &json!({"style": style})).unwrap(); + let undo = s.undo_depth().0; + let ev = |k, p: Point| PointerEvent::at(k, p.x, p.y, 1.0); + s.pointer(&ev(PointerKind::Down, Point::new(50.0, 50.0))).unwrap(); + s.pointer(&ev(PointerKind::Move, Point::new(200.0, 150.0))).unwrap(); + s.pointer(&ev(PointerKind::Move, to)).unwrap(); + let drawing = the_connector(&s).unwrap_or_else(|| panic!("{style}: the connector shows while dragged")); + let ov = &s.tool.overlay; + if to.x < 400.0 { + // Over b: its four attach points, the one the end is at lit, and b outlined. + assert_eq!(ov.drop_target, Some(b)); + let lit: Vec<[f64; 2]> = ov.attach.iter().filter(|a| a.1).map(|a| a.0).collect(); + let end = *drawn(&s, drawing.id).last().unwrap(); + assert_eq!((ov.attach.len(), lit), (4, vec![[end.x, end.y]]), "{style}"); + } else { + assert!(ov.attach.is_empty() && ov.drop_target.is_none(), "a free end"); + } + assert!(s.tool.overlay.rubber.is_none(), "no rubber band: the connector itself shows"); + s.pointer(&ev(PointerKind::Up, to)).unwrap(); + let made = the_connector(&s).unwrap(); + assert_eq!(made.kind, drawing.kind, "{style} to {to:?}: the same path"); + assert_eq!((made.transform, made.width, made.height), (drawing.transform, drawing.width, drawing.height)); + assert_eq!((&made.strokes, &made.stroke, &made.board), (&drawing.strokes, &drawing.stroke, &drawing.board), "line, caps and ends"); + assert_eq!(s.undo_depth().0, undo + 1, "one undo step"); + assert!(s.tool.overlay.attach.is_empty()); + } + } + // Escape mid-drag: the preview goes and nothing is made. + let mut s = board(); + s.execute("tool.connector", &json!({})).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Down, 0.0, 0.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Move, 200.0, 0.0, 1.0)).unwrap(); + assert!(the_connector(&s).is_some()); + s.escape().unwrap(); + assert!(the_connector(&s).is_none()); +} + +#[test] +fn a_connector_end_snaps_to_the_attach_point_under_the_pointer() { + let mut s = board(); + let mut shape = |x: f64, y: f64| id(&s.execute("board.shape", &json!({"x": x, "y": y, "width": 100, "height": 100})).unwrap(), "id"); + let (a, b) = (shape(0.0, 0.0), shape(300.0, 200.0)); + s.execute("tool.connector", &json!({"style": "straight"})).unwrap(); + let ev = |k, x, y| PointerEvent::at(k, x, y, 1.0); + // Hovering: the object's attach points, lit only near one. + s.pointer(&ev(PointerKind::Hover, 50.0, 50.0)).unwrap(); + assert_eq!(s.tool.overlay.attach.len(), 4); + assert!(s.tool.overlay.attach.iter().all(|a| !a.1)); + s.pointer(&ev(PointerKind::Hover, 50.0, 3.0)).unwrap(); + assert_eq!(s.tool.overlay.attach.iter().filter(|a| a.1).map(|a| a.0).collect::>(), vec![[50.0, 0.0]]); + s.pointer(&ev(PointerKind::Hover, 900.0, 900.0)).unwrap(); + assert!(s.tool.overlay.attach.is_empty()); + // From near a's right middle to near b's top middle: those sides, kept. + s.pointer(&ev(PointerKind::Down, 97.0, 50.0)).unwrap(); + s.pointer(&ev(PointerKind::Move, 352.0, 204.0)).unwrap(); + s.pointer(&ev(PointerKind::Up, 352.0, 204.0)).unwrap(); + let k = the_connector(&s).unwrap().id; + assert_eq!((connector(&s, k).from, connector(&s, k).to), (End::Node { node: a, side: Side::Right }, End::Node { node: b, side: Side::Top })); + assert_eq!(drawn(&s, k), vec![Point::new(100.0, 50.0), Point::new(350.0, 200.0)]); +} diff --git a/crates/engine/src/board_tools_tests.rs b/crates/engine/src/board_tools_tests.rs index 4109adda..6129163d 100644 --- a/crates/engine/src/board_tools_tests.rs +++ b/crates/engine/src/board_tools_tests.rs @@ -207,7 +207,7 @@ fn the_shape_stamp_highlighter_and_connector_tools_and_double_clicks() { click(&mut s, 100.0, 100.0); s.pointer(&PointerEvent::at(PointerKind::Down, 60.0, 120.0, 1.0)).unwrap(); s.pointer(&PointerEvent::at(PointerKind::Move, 100.0, 140.0, 1.0)).unwrap(); - assert!(s.tool.overlay.rubber.is_some(), "the rubber band shows while dragging"); + assert!(!s.doc().unwrap().nodes().any(|n| matches!(n.board, Some(BoardItem::Connector(_)))), "nothing previewed: none would be made"); s.pointer(&PointerEvent::at(PointerKind::Up, 100.0, 140.0, 1.0)).unwrap(); assert!(!s.doc().unwrap().nodes().any(|n| matches!(n.board, Some(BoardItem::Connector(_))))); // From the shape out to empty canvas: a connector to a free point. diff --git a/crates/engine/src/boards/board/connectors.rs b/crates/engine/src/boards/board/connectors.rs index 14760331..fbf4bea4 100644 --- a/crates/engine/src/boards/board/connectors.rs +++ b/crates/engine/src/boards/board/connectors.rs @@ -48,11 +48,26 @@ pub(super) fn connector_cmd(s: &mut Session, p: &Value) -> Result { Some(v) => routing_of(cmd, &v)?, None => s.tool.board.routing, }; - let start = cap_of(cmd, p, "startCap")?.unwrap_or(StrokeCap::None); - let end = cap_of(cmd, p, "endCap")?.unwrap_or(StrokeCap::TriangleArrow); - let c = Connector { from, to, routing }; + let start = cap_of(cmd, p, "startCap")?.unwrap_or(CAPS.0); + let end = cap_of(cmd, p, "endCap")?.unwrap_or(CAPS.1); let page = s.page()?; let doc = s.doc_mut()?; + let id = make(doc, page, Connector { from, to, routing }, (start, end))?; + let mut out = json!({"id": id}); + if let Some(text) = string(p, "label").filter(|t| !t.trim().is_empty()) { + out["label"] = json!(set_label(doc, id, &text)?); + } + s.set_selection(vec![id]); + Ok(out) +} + +/// A new connector's (start, end) caps. +pub(super) const CAPS: (StrokeCap, StrokeCap) = (StrokeCap::None, StrokeCap::TriangleArrow); + +/// Connector `c` added to `page` with its line, `caps` (start, end) and route: what +/// `board.connector` makes, and the connector tool shows while it is dragged. +pub(super) fn make(doc: &mut Document, page: NodeId, c: Connector, caps: (StrokeCap, StrokeCap)) -> Result { + let cmd = "board.connector"; let pts = route(doc, &c).ok_or_else(|| bad(cmd, "the ends can't be placed"))?; if pts.windows(2).all(|w| w.first() == w.get(1)) { return Err(bad(cmd, "the ends are in the same place")); @@ -62,17 +77,11 @@ pub(super) fn connector_cmd(s: &mut Session, p: &Value) -> Result { n.strokes = vec![Paint::solid(LINE)]; n.stroke.weight = LINE_WEIGHT; n.stroke.join = StrokeJoin::Round; - n.stroke.cap_start = start; - n.stroke.cap_end = end; + (n.stroke.cap_start, n.stroke.cap_end) = caps; n.board = Some(BoardItem::Connector(c)); })?; - place_route(doc, id, &pts, routing); - let mut out = json!({"id": id}); - if let Some(text) = string(p, "label").filter(|t| !t.trim().is_empty()) { - out["label"] = json!(set_label(doc, id, &text)?); - } - s.set_selection(vec![id]); - Ok(out) + place_route(doc, id, &pts, c.routing); + Ok(id) } /// The connector `id` names: a connector, or the label on one. @@ -121,6 +130,14 @@ pub(super) fn end_json(e: End) -> Value { } } +/// The side a connector end names, as the commands take it (none for a free point). +pub(super) fn side_json(e: End) -> Value { + match e { + End::Node { side, .. } => json!(side), + End::Point { .. } => Value::Null, + } +} + /// The end of connector `id` that stays while its other one (`from`: its start) is dragged. pub(super) fn other_end(doc: &Document, id: NodeId, from: bool) -> Option { match &doc.node(id)?.board { diff --git a/crates/engine/src/boards/board/tools.rs b/crates/engine/src/boards/board/tools.rs index cb3fc005..bd47d50d 100644 --- a/crates/engine/src/boards/board/tools.rs +++ b/crates/engine/src/boards/board/tools.rs @@ -1,12 +1,18 @@ -use super::connectors::{end_json, other_end}; -use super::derived::ends_now; +use super::connectors::{CAPS, end_json, make, other_end, side_json}; +use super::derived::{ends_now, route}; use super::edit::label_of; use super::items::edit_text; use super::{ - BoardItem, Document, ERASER_PX, End, EngineError, Gesture, NodeId, Point, PointerEvent, Result, SHAPE, STICKY, Session, Side, ToolId, id_of, - is_board, json, + BoardItem, Connector, Document, ERASER_PX, End, EngineError, Gesture, NodeId, Point, PointerEvent, Result, SHAPE, STICKY, Session, Side, ToolId, + id_of, is_board, json, }; +/// How near (screen pixels) the pointer must be to the middle of an object's side for a connector +/// end to attach there rather than to the side facing the other end. +const ATTACH_PX: f64 = 10.0; +/// The sides an object takes a connector end at (their middles are its attach points). +const SIDES: [Side; 4] = [Side::Top, Side::Right, Side::Bottom, Side::Left]; + // --------------------------------------------------------------------------------------------- // The board tools. @@ -51,7 +57,8 @@ impl Session { ToolId::Connector => { let from = self.end_at(ev); self.tool.gesture = Gesture::Connect { from, start: ev.p() }; - Ok(()) + // While it is dragged the connector shows as it will be made (a preview). + self.begin_interaction("Connector") } ToolId::Eraser => { self.begin_interaction("Erase")?; @@ -69,47 +76,87 @@ impl Session { } } - /// What a connector end at the pointer attaches to: the object there, or the point. + /// What a connector end at the pointer attaches to: the object there (at the side whose middle + /// is within [`ATTACH_PX`], else the side facing the other end), or the point. fn end_at(&self, ev: &PointerEvent) -> End { + let zoom = ev.zoom.max(1e-6); let hit = (|| { - let doc = self.doc().ok()?; - let leaf = self.hit(doc, self.page().ok()?, ev.p(), 2.0 / ev.zoom.max(1e-6))?; + let ds = self.ds().ok()?; + // Under the connector being dragged out (previewed in the document), not on it. + let doc = ds.interaction.as_ref().map_or(&*ds.doc, |it| &*it.doc); + let leaf = self.hit(doc, ds.page, ev.p(), 2.0 / zoom)?; let t = figcraft_doc::geometry::select_target(doc, leaf, &[], false); let n = doc.node(t)?; - (!matches!(n.board, Some(BoardItem::Connector(_)) | Some(BoardItem::Label { .. }) | Some(BoardItem::Ink))).then_some(t) + if matches!(n.board, Some(BoardItem::Connector(_)) | Some(BoardItem::Label { .. }) | Some(BoardItem::Ink)) { + return None; + } + let b = doc.world_bounds(t); + let far = |s: &Side| b.map(|b| (s.point(b) - ev.p()).hypot() * zoom).filter(|d| *d <= ATTACH_PX); + let side = SIDES.iter().filter_map(|s| Some((*s, far(s)?))).min_by(|x, y| x.1.total_cmp(&y.1)).map_or(Side::Auto, |(s, _)| s); + Some(End::Node { node: t, side }) })(); - match hit { - Some(node) => End::Node { node, side: Side::Auto }, - None => End::Point { x: ev.x, y: ev.y }, - } + hit.unwrap_or(End::Point { x: ev.x, y: ev.y }) } - pub(crate) fn connect_drag(&mut self, ev: &PointerEvent, from: End, start: Point) { - let a = match from { - End::Point { x, y } => Point::new(x, y), - End::Node { .. } => start, + /// Where a connector dragged from `from` (pressed at `start`) to the pointer ends: none for a + /// click, or back on the object it started from (nothing is made). + fn drag_end(&self, ev: &PointerEvent, from: End, start: Point) -> Option { + if (ev.p() - start).hypot() * ev.zoom.max(1e-6) < 4.0 { + return None; + } + let to = self.end_at(ev); + (to.node().is_none() || to.node() != from.node()).then_some(to) + } + + /// The object `end` is on (if any) outlined, with its attach points; the one the end is at + /// (`at`, else its named side's) lit. + fn show_attach(&mut self, end: Option, at: Option) { + let attach = match end { + Some(End::Node { node, side }) => self.doc().ok().and_then(|d| d.world_bounds(node)).map(|b| { + let on = at.or_else(|| (side != Side::Auto).then(|| side.point(b))); + SIDES.map(|s| s.point(b)).map(|p| ([p.x, p.y], on.is_some_and(|o| (o - p).hypot() < 1e-6))).to_vec() + }), + _ => None, }; - self.tool.overlay.rubber = Some([a.x, a.y, ev.x, ev.y]); - self.tool.overlay.drop_target = from.node(); + self.tool.overlay.attach = attach.unwrap_or_default(); + self.tool.overlay.drop_target = end.and_then(|e| e.node()); + } + + /// The pointer moving with the connector tool and no button down: the attach points under it. + pub(crate) fn connector_hover(&mut self, ev: &PointerEvent) { + let end = self.doc().is_ok_and(is_board).then(|| self.end_at(ev)); + self.show_attach(end, None); + } + + /// The connector being dragged out, drawn as it will be made (a preview) to the attach point + /// or free point under the pointer. + pub(crate) fn connect_drag(&mut self, ev: &PointerEvent, from: End, start: Point) { self.tool.gesture = Gesture::Connect { from, start }; + let c = self.drag_end(ev, from, start).map(|to| Connector { from, to, routing: self.tool.board.routing }); + if let Ok(page) = self.page() { + // One that can't be placed shows nothing, as none would be made. + let _ = self.preview(|d, _| { + if let Some(c) = c { + let _ = make(d, page, c, CAPS); + } + Ok(()) + }); + } + let at = c.and_then(|c| route(self.doc().ok()?, &c)).and_then(|pts| pts.last().copied()); + self.show_attach(c.map(|c| c.to), at); if let Ok(ds) = self.ds_mut() { ds.revision += 1; } } pub(crate) fn connect_up(&mut self, ev: &PointerEvent, from: End, start: Point) -> Result<()> { - self.tool.overlay.rubber = None; - self.tool.overlay.drop_target = None; - // A click, not a drag: nothing to join. - if (ev.p() - start).hypot() * ev.zoom.max(1e-6) < 4.0 { - return Ok(()); - } - let to = self.end_at(ev); - if to.node().is_some() && to.node() == from.node() { - return Ok(()); - } + self.show_attach(None, None); + // The preview goes: the command makes the connector, one undo step. + self.cancel_interaction()?; + let Some(to) = self.drag_end(ev, from, start) else { return Ok(()) }; let style = self.tool.board.routing.id(); - self.execute("board.connector", &json!({"from": end_json(from), "to": end_json(to), "style": style}))?; + let p = json!({"from": end_json(from), "fromSide": side_json(from), "to": end_json(to), "toSide": side_json(to), "style": style}); + self.execute("board.connector", &p)?; self.board_placed(); Ok(()) } @@ -122,7 +169,8 @@ impl Session { let fixed = ends_now(doc, id).map(|(a, b)| if from { b } else { a }); let other = other_end(doc, id, from).and_then(|e| e.node()); self.tool.overlay.rubber = fixed.map(|f| [f.x, f.y, ev.x, ev.y]); - self.tool.overlay.drop_target = self.end_at(ev).node().filter(|n| Some(*n) != other); + let target = Some(self.end_at(ev)).filter(|e| e.node().is_none() || e.node() != other); + self.show_attach(target, None); if let Ok(ds) = self.ds_mut() { ds.revision += 1; } @@ -132,6 +180,7 @@ impl Session { /// A click, or a drop on the object the other end is on, changes nothing. pub(crate) fn reattach_up(&mut self, ev: &PointerEvent, id: NodeId, from: bool, start: Point) -> Result<()> { self.tool.overlay.rubber = None; + self.show_attach(None, None); if (ev.p() - start).hypot() * ev.zoom.max(1e-6) < 4.0 { return Ok(()); } @@ -140,7 +189,8 @@ impl Session { if target.node().is_some() && target.node() == other { return Ok(()); } - self.execute("board.reattach", &json!({"id": id, "end": if from { "from" } else { "to" }, "target": end_json(target)}))?; + let end = if from { "from" } else { "to" }; + self.execute("board.reattach", &json!({"id": id, "end": end, "target": end_json(target), "side": side_json(target)}))?; Ok(()) } diff --git a/crates/engine/src/canvas/tools.rs b/crates/engine/src/canvas/tools.rs index 959dce9c..b7c18572 100644 --- a/crates/engine/src/canvas/tools.rs +++ b/crates/engine/src/canvas/tools.rs @@ -336,6 +336,9 @@ pub struct Overlay { pub rubber_handle: Option<[f64; 2]>, /// Frame/section the pointer is drawing or dropping into (highlighted). pub drop_target: Option, + /// Connector tool: the attach points (side middles, page x, y) of the object under the pointer, + /// and whether the connector end is at each. + pub attach: Vec<([f64; 2], bool)>, /// Auto layout insertion indicator (x0, y0, x1, y1). pub insert_line: Option<[f64; 4]>, /// The instance an Alt-drop of a component from the Assets panel would swap (highlighted). diff --git a/crates/engine/src/canvas/tools/pointer.rs b/crates/engine/src/canvas/tools/pointer.rs index 550c4a66..e470a179 100644 --- a/crates/engine/src/canvas/tools/pointer.rs +++ b/crates/engine/src/canvas/tools/pointer.rs @@ -257,6 +257,9 @@ impl Session { } } self.tool.overlay.guides = guides; + if tool == ToolId::Connector { + self.connector_hover(ev); + } let ds = self.ds_mut()?; if ds.hover != hover { ds.hover = hover; diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index 2d4d82d5..5e932324 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -71,6 +71,7 @@ pub(super) fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec quad(painter, q, stroke(if figcraft_engine::components::is_slot(&doc, d) { COMPONENT } else { accent(painter) }, 1.0)); } slot_badges(app, painter, &doc, &sel, ov.drop_target); + attach_points(app, painter, &ov.attach); if let Some(t) = ov.swap_target { swap_highlight(app, painter, &doc, t); } @@ -333,6 +334,18 @@ fn selection_overlay( size_label } +/// The connector tool's attach points on the object under the pointer; the one its end is at is a +/// larger see-through ring, so the end's cap shows inside it. +fn attach_points(app: &FigApp, painter: &egui::Painter, points: &[([f64; 2], bool)]) { + let col = accent(painter); + for ([x, y], on) in points { + let c = app.to_screen(*x, *y); + let (r, fill) = if *on { (7.0, col.gamma_multiply(0.25)) } else { (4.0, Color32::WHITE) }; + painter.circle_filled(c, r, fill); + painter.circle_stroke(c, r, stroke(col, 1.5)); + } +} + fn pen_overlay(app: &FigApp, painter: &egui::Painter, zoom: f64) { let ov = &app.session.tool.overlay; if !ov.pen.is_empty() { @@ -369,7 +382,8 @@ fn pen_overlay(app: &FigApp, painter: &egui::Painter, zoom: f64) { } } } else if let Some([x0, y0, x1, y1]) = ov.rubber { - // A connector being drawn, or one of its ends dragged: from where it starts to the pointer. + // A selected connector's end being dragged: from its other end to the pointer (one being + // drawn shows itself, previewed in the document). painter.line_segment([app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(accent(painter), 1.5)); } } diff --git a/docs/boards-parity.md b/docs/boards-parity.md index 116295b4..383a6933 100644 --- a/docs/boards-parity.md +++ b/docs/boards-parity.md @@ -1,6 +1,6 @@ # Boards parity -> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** minor (templates and tables; facilitation: a shared timer and voting sessions; the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre) +> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-29 · **Change:** minor (the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); templates and tables; facilitation: a shared timer and voting sessions; the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre) **Breadth ~47%, ready ~29% (estimated, erring low).** A board is a FigCraft file of its own kind (`file.newBoard`) with a dot grid on the page, sticky notes, shapes with text, connectors that @@ -34,7 +34,7 @@ all end with the same geometry. | Sticky options: hide the author, change size presets, sticky stacks | partial | `board.sticky {author: false}`; no size presets or stacks | | Shapes with text | done | rectangle, rounded rectangle, ellipse, diamond, triangle, speech bubble, chevron; text centred and wrapping inside the outline; shape type and text size from the contextual bar | | Shape auto-fit to its text, more shapes (cylinder, cross, star…), shape libraries | missing | | -| Connectors | done | from a layer's side (the facing one, or one named) to another layer or a free point, straight, elbow or curved, arrowheads (the stroke's caps), a label in the middle of the route by length (double-click to add); re-routed when either end moves or resizes; a deleted end stays where it was | +| Connectors | done | from a layer's side (the facing one, or one named) to another layer or a free point, straight, elbow or curved, arrowheads (the stroke's caps), a label in the middle of the route by length (double-click to add); while one is dragged out it shows as it will be made (route, line and arrowhead), the object under the pointer outlined with its four attach points (side middles), the one the end goes to ringed, and within 10 px of one the end keeps that side; re-routed when either end moves or resizes; a deleted end stays where it was | | Curved connectors, elbows around objects, dragging an end to re-attach | done | `board.setStyle {style: curved}` (and the contextual bar): one curve leaving and arriving along the sides; an elbow whose short way would cross what it joins goes around their boxes, 24 units clear; a selected connector shows its two ends as handles, and dragging one onto an object attaches it there (the object is outlined while dragging), onto empty canvas leaves it free (`board.reattach {id, end, target, side?}`, one undo step) | | Connector waypoints placed by hand | missing | routes are computed from the ends; no bend points of your own | | Quick-create handles (drag out a connected copy) | missing | | @@ -63,6 +63,7 @@ all end with the same geometry. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M12, #1387 (connector tool): while a connector is dragged out with the connector tool, the canvas shows the connector itself as it will be made (its straight, elbow or curved route, line, weight and arrowhead, built by the same code as `board.connector`), not a thin line; the object under the pointer is outlined with its four attach points (the middles of its sides), the one the end goes to ringed; within 10 px of one the end keeps that side (`fromSide`/`toSide`, and `side` when re-attaching). Hovering with the tool shows the attach points too. Escape drops the preview; releasing makes the connector in one undo step. Tests: `board_connector_tests`. Numbers unchanged | | 2026-10-24 | minor | M12: the dot grid on board pages (a render pass under the layers), dragging a selected connector's end onto another object or empty canvas (`board.reattach`), curved connectors, and elbows that go around the objects they join. Breadth ~35% → ~37%, ready ~20% → ~21%, ~30–60 h → ~28–56 h | | 2026-10-24 | minor | Facilitation (M12): a shared timer and voting sessions. Breadth ~35% → ~40%, ready ~20% → ~24%, ~30–60 h → ~26–54 h | | 2026-10-24 | minor | Templates (retrospective, brainstorm, weekly planner, flow chart, kanban board) from a toolbar picker or `board.template`, and tables (`board.table`, `board.tableEdit`): breadth ~35% → ~40%, ready ~20% → ~24%, remaining 30–60 h → 26–54 h | diff --git a/docs/roadmap.md b/docs/roadmap.md index 73bc16de..e8f8fc31 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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-10-28 · **Change:** minor (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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M12, #1387: while a connector is dragged out with the connector tool, the canvas shows the connector itself as it will be made (its straight, elbow or curved route, line, weight and arrowhead, built by the same code as `board.connector`), not a thin line; the object under the pointer is outlined with its four attach points (the middles of its sides), the one the end goes to ringed; within 10 px of one the end keeps that side (`fromSide`/`toSide`, and `side` when re-attaching). Hovering with the tool shows the attach points too. Escape drops the preview; releasing makes the connector in one undo step. Tests: `board_connector_tests`. Numbers unchanged | | 2026-10-29 | minor | Large images on the canvas (#1483): an image shown smaller than its pixels draws on the canvas from a copy scaled down to 4096 or 8192 px on its longer side, the smallest that holds what the screen shows (`figcraft_render::images::shown`), cached beside the image itself in the same 512 MB budget. Two 16 000 × 10 000 images took 640 MB each and evicted each other on every frame that drew both; as copies they take 80 MB together zoomed out and 336 MB at 8192. Moving one over the other went from about 2.1 s a frame to 11 ms zoomed to fit and 15 ms at 30% in the app (debug build). Closer than that, and for tiles, the canvas draws from the image itself, so 100% and closer stay sharp; a view of both at 100% still takes about 2 s a frame, and an image with shadows still decodes in full for them. Exports, thumbnails and every other render are unchanged. Tests: `large_image_tests`. Numbers unchanged | | 2026-10-29 | patch | M8: the progressive JPEG check from #1491 (a frame sampling a component more finely than its first, which the JPEG decoder asserts on, and in the browser a panic aborts the page) moved to `figcraft_format::jpeg` and now also guards the pictures files carry (`thumbnail::decode_thumbnail`: the home screen and recent files), so a crafted `.figcraft`, `.fig` or `.sketch` thumbnail or image file shows no picture instead of crashing. The canvas, exports and SVG/PDF keep it through the renderer. Regression tests in `jpeg_tests` (format) and `jpeg_sampling_tests` (render). Numbers unchanged | | 2026-10-29 | minor | M6, #1234: `.fig` import resolves style links that name a library style by its asset key and version instead of a guid (the file's own copy of the style), as variable links already were; text in instances of library components kept a stale copy of the style's values and no link. The linked text style beats the copies of its values on the layer and on the override rows below the row that links it, and rows above that row (an outer instance's size, say) still win. Override rows that restate a nested level's overrides (`overrideLevel`) apply at that level, below the instance's own rows, instead of in wire order. Synthetic regression tests in `text_style_tests`. Numbers unchanged | From 08173cc51e5422bca5900a82a8d91e92e210def2 Mon Sep 17 00:00:00 2001 From: BFlat Date: Thu, 29 Oct 2026 18:44:03 +0700 Subject: [PATCH 17/63] M9: a refused batch's fixes in linear time (#1063) (#1522) The server's fixes for a refused batch walked every deleted layer's whole subtree again and compared each document-level fix with all the earlier ones, so a batch deleting a deep chain took quadratic time on the hub thread. Each subtree walk now stops at a layer already restored, and document-level fixes are looked up and sent once per target. The fixes are unchanged. A refused batch deleting a 20,000-deep chain plus 40,000 comment puts: 27.2 s before, 0.14 s after (debug build). Fixes #1063 --- ROADMAP.md | 3 ++- crates/collab/src/server.rs | 31 +++++++++++++----------- crates/collab/src/server_tests.rs | 39 ++++++++++++++++++++++++++++++- docs/collaboration-parity.md | 5 ++-- docs/roadmap.md | 3 ++- 5 files changed, 63 insertions(+), 18 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 46d1ffe0..f28ba385 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-29 · **Change:** minor (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-10-29 · **Change:** minor (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 @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | A refused batch's fixes in linear time (#1063, from a security audit): the server restores each deleted layer once, walking each subtree once and stopping at layers already restored, and looks up and sends each style, variable, comment or document property fix once. The fixes sent back are unchanged. Deleting every layer of a 20,000-deep chain in a refused batch took 27 s on the hub thread in a debug build (each deleted layer's whole subtree walked again, each entry fix compared with all the earlier ones); it now takes 0.1 s (`a_refused_deep_delete_restores_each_node_once_in_linear_time`). | | 2026-10-29 | minor | M12, #1387: while a connector is dragged out with the connector tool, the canvas shows the connector itself as it will be made (its straight, elbow or curved route, line, weight and arrowhead, built by the same code as `board.connector`), not a thin line; the object under the pointer is outlined with its four attach points (the middles of its sides), the one the end goes to ringed; within 10 px of one the end keeps that side (`fromSide`/`toSide`, and `side` when re-attaching). Hovering with the tool shows the attach points too. Escape drops the preview; releasing makes the connector in one undo step. Tests: `board_connector_tests`. Numbers unchanged | | 2026-10-29 | minor | Large images on the canvas (#1483): an image shown smaller than its pixels draws on the canvas from a copy scaled down to 4096 or 8192 px on its longer side, the smallest that holds what the screen shows (`figcraft_render::images::shown`), cached beside the image itself in the same 512 MB budget. Two 16 000 × 10 000 images took 640 MB each and evicted each other on every frame that drew both; as copies they take 80 MB together zoomed out and 336 MB at 8192. Moving one over the other went from about 2.1 s a frame to 11 ms zoomed to fit and 15 ms at 30% in the app (debug build). Closer than that, and for tiles, the canvas draws from the image itself, so 100% and closer stay sharp; a view of both at 100% still takes about 2 s a frame, and an image with shadows still decodes in full for them. Exports, thumbnails and every other render are unchanged. Tests: `large_image_tests`. Numbers unchanged | | 2026-10-29 | patch | M8: the progressive JPEG check from #1491 (a frame sampling a component more finely than its first, which the JPEG decoder asserts on, and in the browser a panic aborts the page) moved to `figcraft_format::jpeg` and now also guards the pictures files carry (`thumbnail::decode_thumbnail`: the home screen and recent files), so a crafted `.figcraft`, `.fig` or `.sketch` thumbnail or image file shows no picture instead of crashing. The canvas, exports and SVG/PDF keep it through the renderer. Regression tests in `jpeg_tests` (format) and `jpeg_sampling_tests` (render). Numbers unchanged | diff --git a/crates/collab/src/server.rs b/crates/collab/src/server.rs index d8aab594..0f3e8c9f 100644 --- a/crates/collab/src/server.rs +++ b/crates/collab/src/server.rs @@ -557,6 +557,7 @@ impl Server { let doc = self.state.document(); let mut props: BTreeMap> = BTreeMap::new(); let mut gone: BTreeSet = BTreeSet::new(); + let (mut asked, mut sent) = (BTreeSet::new(), BTreeSet::new()); let mut out = Vec::new(); for c in changes { match c { @@ -569,9 +570,11 @@ impl Server { Change::Delete { id } => { gone.insert(*id); } + // The same target has the same fix: look each one up once, and send each fix once. other => { - if let Some(fix) = docprops::current(doc, other)? - && !out.contains(&fix) + if Target::of(other).is_some_and(|t| asked.insert(t)) + && let Some(fix) = docprops::current(doc, other)? + && Target::of(&fix).is_some_and(|t| sent.insert(t)) { out.push(fix); } @@ -589,19 +592,21 @@ impl Server { nodes.extend(t.write(self.state.value(&t)?.as_ref())); } } - let mut restored = std::collections::BTreeSet::new(); + // Each deleted node's subtree, pre-order, every node once: a restored node's whole subtree + // went out with it, so the walk stops there (deleting a deep chain stays linear). + let mut restored = BTreeSet::new(); for id in gone { - match doc.node(id) { - Some(_) => { - for d in doc.subtree(id) { - if restored.insert(d) - && let Some(n) = doc.node(d) - { - nodes.push(create_change(n, self.state.key(d))?); - } - } + if !doc.contains(id) { + nodes.push(Change::Delete { id }); + continue; + } + let mut stack = vec![id]; + while let Some(d) = stack.pop() { + let Some(n) = doc.node(d) else { continue }; + if restored.insert(d) { + nodes.push(create_change(n, self.state.key(d))?); + stack.extend(n.children.iter().rev()); } - None => nodes.push(Change::Delete { id }), } } nodes.extend(out); diff --git a/crates/collab/src/server_tests.rs b/crates/collab/src/server_tests.rs index 15d86f89..da12f7f3 100644 --- a/crates/collab/src/server_tests.rs +++ b/crates/collab/src/server_tests.rs @@ -2,7 +2,7 @@ use figcraft_doc::{Document, Node, NodeId, NodeKind}; -use crate::change::{Change, Json, Prop}; +use crate::change::{Change, Entry, Json, Prop}; use crate::message::{ClientMsg, Hello, PROTOCOL, Presence, ServerMsg}; use crate::replica::Mode; use crate::{Client, Limits, NoRecompute, Outgoing, Server}; @@ -217,6 +217,43 @@ fn fixes_roll_a_rejected_client_back_to_the_server_state() { assert_eq!(b.view(), s.replica()); } +#[test] +fn a_refused_deep_delete_restores_each_node_once_in_linear_time() { + const DEPTH: usize = 20_000; + let (mut d, _, frame) = doc(); + let mut chain = vec![frame]; + while chain.len() < DEPTH { + let parent = *chain.last().unwrap(); + chain.push(d.create(parent, NodeKind::Frame, "F", 0.0, 0.0, 1.0, 1.0).unwrap()); + } + let mut s = Server::new(d, "tok", Limits::default()); + let a = join(&mut s); + // Every node of the chain deleted, plus a rename of a node nobody has: refused whole. + let missing = NodeId(u64::MAX >> 1); + let mut changes: Vec = chain.iter().map(|&id| Change::Delete { id }).collect(); + changes.push(Change::Set { id: missing, prop: Prop::Name, value: Json("x".into()) }); + // As many comment threads put, each twice: they go back to not existing, once each. + let put = |c: u64| Change::Put { entry: Entry::Comment(c), value: Json(serde_json::json!({})) }; + changes.extend((1..=DEPTH as u64).chain(1..=DEPTH as u64).map(put)); + let started = std::time::Instant::now(); + let (_, fixes) = rejected(&mut s, a.id(), changes); + let took = started.elapsed(); + assert_eq!(fixes.first(), Some(&Change::Delete { id: missing })); + let restored: Vec = fixes[1..=DEPTH] + .iter() + .map(|c| match c { + Change::Create { id, .. } => *id, + other => panic!("not a creation: {other:?}"), + }) + .collect(); + assert_eq!(restored, chain, "each deleted node comes back once, parents first"); + let removed: Vec = (1..=DEPTH as u64).map(|c| Change::Remove { entry: Entry::Comment(c) }).collect(); + assert_eq!(fixes[DEPTH + 1..], removed[..], "each entry once, in batch order"); + // Walking every deleted node's subtree again and comparing each fix with all the earlier + // ones took 27 s in a debug build; once each takes 0.1 s. + assert!(took < std::time::Duration::from_secs(10), "{took:?}"); +} + #[test] fn remote_batches_apply_under_pending_ones() { let (d, _, frame) = doc(); diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index c8a9d436..1ab6c588 100644 --- a/docs/collaboration-parity.md +++ b/docs/collaboration-parity.md @@ -1,6 +1,6 @@ # Collaboration parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model, @@ -12,7 +12,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Feature | Figma | FigCraft | Hours | |---|---|---|---| -| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). Left: server archives beyond Linux | 25–55 | +| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). A refused batch's fixes take time linear in the batch: each deleted layer is restored once and each entry fix looked up once (#1063). Left: server archives beyond Linux | 25–55 | | Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors, selection outlines, avatars, follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 | | Cursor chat, audio, high-fives | yes | **partial, in live sessions**: cursor chat (`/` opens a bubble at your pointer; everyone sees it as you type, it fades a few seconds after the last change, Enter sends and Escape takes it back, 100 characters; `collab.cursorChat` for agents); sent as ephemeral presence the host checks and rate-limits, never stored. No audio, high-fives or emote reactions | 2–5 | | Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen; the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser)); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer: no accounts, teams or projects | 8–17 | @@ -26,6 +26,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | A refused batch's fixes in linear time (#1063, from a security audit): the server restores each deleted layer once, walking each subtree once and stopping at layers already restored, and looks up and sends each style, variable, comment or document property fix once. The fixes sent back are unchanged. Deleting every layer of a 20,000-deep chain in a refused batch took 27 s on the hub thread in a debug build (each deleted layer's whole subtree walked again, each entry fix compared with all the earlier ones); it now takes 0.1 s (`a_refused_deep_delete_restores_each_node_once_in_linear_time`). | | 2026-10-29 | minor | Image entries are checked as they apply (#1086, from a security audit): a key must be 1–128 ASCII letters, digits, `-` or `_` — the same rule a file load reads `images/` members by (`figcraft_doc::ImageData::is_valid_key`, shared with the format crate, which now also reads `-` and `_` names back, so the REST importer's `rest-image-missing` placeholder survives a save) — so no collaborator can write `images/../../x` into everyone's saved file; bytes carried under a key with the shape of a content hash must be what that key hashes to (lowercase sha256 hex, exactly the form `figcraft_format::image_hash` produces — an uppercase look-alike is refused), a side is at most 65 535 px (JPEG and GIF size fields are 16-bit and no image this app realistically opens reaches beyond it), and the decoded bytes at most 256 MiB (`figcraft_doc::ImageData::MAX_BYTES`, the cap the `.fig`, `.sketch` and REST importers read images with, so an image a document imported still reaches everyone) — with the encoded text capped before decoding so a hostile payload can't force a large allocation. A key that already holds an image can't be given other bytes (producers key images by their bytes, and the REST importer's placeholder keys always hold the same picture). A refused entry refuses its batch like any invalid change, so every replica's image store stays clean. History written before these checks — a server's snapshot or log, a file published to a server, a snapshot a client joins with — applies without the image entries they refuse (judged against the state they apply to, so a logged replacement under an existing key, or removal of an image in use, is left out too), instead of the file not opening or a restart dropping every edit after the image; a restarted server that left image entries out, or whose saved log puts images, catches every resuming client up with its snapshot instead of that log, which it can't check against what each client holds. An image a layer or style still shows can't be removed: removing it and putting other bytes under its key would otherwise swap the picture. Keys that aren't content-hash shaped keep working within the bounds (the svg importer's FNV-128 keys, the REST importer's placeholder under an imageRef), and 0×0 entries — what the format crate stores for sizes it can't probe — stay valid. Tests in `image_tests`. Numbers unchanged | | 2026-10-24 | minor | Branches in live sessions (gap 17): creating, editing, reviewing, updating from main and merging a branch work while its file is in a session. The branch stays with its author and makes its ids in their own client space 2^24 above the main file's, so the server takes the layers it adds; merging is one synced edit and one step of the author's undo history; others' edits on main merge against the branch's own base and conflicts resolve as offline; a view link still refuses merging; a branch made outside the session that adds layers, styles, variables or comments is refused with a reason. A branch's history gets entries for its creation and updates from main. No wire format change. Row wording also brought up to date with #798 (export settings and guides merge item by item). Numbers unchanged | | 2026-10-17 | minor | From community batch #735: server message limits follow `max_file_mb`, so files above 64 MiB publish and open, the client takes at most 1 GiB and the server warns about large limits with open publishing (#705); a damaged generation field no longer drops a log's flushed edits (#687); the web build joins a session from a `#join=` link in its URL (#712, unit-tested and built for wasm, not run in a browser). Percentages and hours unchanged | diff --git a/docs/roadmap.md b/docs/roadmap.md index e8f8fc31..a76d85fb 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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-10-28 · **Change:** minor (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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | A refused batch's fixes in linear time (#1063, from a security audit): the server restores each deleted layer once, walking each subtree once and stopping at layers already restored, and looks up and sends each style, variable, comment or document property fix once. The fixes sent back are unchanged. Deleting every layer of a 20,000-deep chain in a refused batch took 27 s on the hub thread in a debug build (each deleted layer's whole subtree walked again, each entry fix compared with all the earlier ones); it now takes 0.1 s (`a_refused_deep_delete_restores_each_node_once_in_linear_time`). | | 2026-10-29 | minor | M12, #1387: while a connector is dragged out with the connector tool, the canvas shows the connector itself as it will be made (its straight, elbow or curved route, line, weight and arrowhead, built by the same code as `board.connector`), not a thin line; the object under the pointer is outlined with its four attach points (the middles of its sides), the one the end goes to ringed; within 10 px of one the end keeps that side (`fromSide`/`toSide`, and `side` when re-attaching). Hovering with the tool shows the attach points too. Escape drops the preview; releasing makes the connector in one undo step. Tests: `board_connector_tests`. Numbers unchanged | | 2026-10-29 | minor | Large images on the canvas (#1483): an image shown smaller than its pixels draws on the canvas from a copy scaled down to 4096 or 8192 px on its longer side, the smallest that holds what the screen shows (`figcraft_render::images::shown`), cached beside the image itself in the same 512 MB budget. Two 16 000 × 10 000 images took 640 MB each and evicted each other on every frame that drew both; as copies they take 80 MB together zoomed out and 336 MB at 8192. Moving one over the other went from about 2.1 s a frame to 11 ms zoomed to fit and 15 ms at 30% in the app (debug build). Closer than that, and for tiles, the canvas draws from the image itself, so 100% and closer stay sharp; a view of both at 100% still takes about 2 s a frame, and an image with shadows still decodes in full for them. Exports, thumbnails and every other render are unchanged. Tests: `large_image_tests`. Numbers unchanged | | 2026-10-29 | patch | M8: the progressive JPEG check from #1491 (a frame sampling a component more finely than its first, which the JPEG decoder asserts on, and in the browser a panic aborts the page) moved to `figcraft_format::jpeg` and now also guards the pictures files carry (`thumbnail::decode_thumbnail`: the home screen and recent files), so a crafted `.figcraft`, `.fig` or `.sketch` thumbnail or image file shows no picture instead of crashing. The canvas, exports and SVG/PDF keep it through the renderer. Regression tests in `jpeg_tests` (format) and `jpeg_sampling_tests` (render). Numbers unchanged | From 3efb2699b675e6b81d2b56625028ba20ab7b9a9d Mon Sep 17 00:00:00 2001 From: BFlat Date: Thu, 29 Oct 2026 19:00:15 +0700 Subject: [PATCH 18/63] M3: the instance-swap menus have a search field that filters as you type (#1338) (#1527) --- ROADMAP.md | 3 +- .../src/component_swap_default_tests.rs | 10 +- .../src/component_swap_search_tests.rs | 69 ++++++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/component_panel.rs | 217 +++++++++++++----- crates/ui-egui/src/panels/right.rs | 2 +- crates/ui-egui/src/panels/right/menu.rs | 12 +- docs/components-parity.md | 5 +- docs/roadmap.md | 3 +- 9 files changed, 256 insertions(+), 67 deletions(-) create mode 100644 crates/ui-egui/src/component_swap_search_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index f28ba385..613e9353 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-29 · **Change:** minor (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-10-29 · **Change:** minor (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 @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M3, #1338: the instance-swap property's menu (on an instance, its main component's default and the component playground) and the instance's swap menu open with a search field at the top that has the keyboard; typing filters the components by name (any case, headings without matches drop out), an empty result reads "No matching components", Up and Down step through the matches and Enter picks one (the first without a step), Escape clears the search and then closes the menu. Only the list scrolls; the menu grows back when the search is cleared. Tests: `component_swap_search_tests`. Numbers unchanged | | 2026-10-29 | minor | A refused batch's fixes in linear time (#1063, from a security audit): the server restores each deleted layer once, walking each subtree once and stopping at layers already restored, and looks up and sends each style, variable, comment or document property fix once. The fixes sent back are unchanged. Deleting every layer of a 20,000-deep chain in a refused batch took 27 s on the hub thread in a debug build (each deleted layer's whole subtree walked again, each entry fix compared with all the earlier ones); it now takes 0.1 s (`a_refused_deep_delete_restores_each_node_once_in_linear_time`). | | 2026-10-29 | minor | M12, #1387: while a connector is dragged out with the connector tool, the canvas shows the connector itself as it will be made (its straight, elbow or curved route, line, weight and arrowhead, built by the same code as `board.connector`), not a thin line; the object under the pointer is outlined with its four attach points (the middles of its sides), the one the end goes to ringed; within 10 px of one the end keeps that side (`fromSide`/`toSide`, and `side` when re-attaching). Hovering with the tool shows the attach points too. Escape drops the preview; releasing makes the connector in one undo step. Tests: `board_connector_tests`. Numbers unchanged | | 2026-10-29 | minor | Large images on the canvas (#1483): an image shown smaller than its pixels draws on the canvas from a copy scaled down to 4096 or 8192 px on its longer side, the smallest that holds what the screen shows (`figcraft_render::images::shown`), cached beside the image itself in the same 512 MB budget. Two 16 000 × 10 000 images took 640 MB each and evicted each other on every frame that drew both; as copies they take 80 MB together zoomed out and 336 MB at 8192. Moving one over the other went from about 2.1 s a frame to 11 ms zoomed to fit and 15 ms at 30% in the app (debug build). Closer than that, and for tiles, the canvas draws from the image itself, so 100% and closer stay sharp; a view of both at 100% still takes about 2 s a frame, and an image with shadows still decodes in full for them. Exports, thumbnails and every other render are unchanged. Tests: `large_image_tests`. Numbers unchanged | diff --git a/crates/ui-egui/src/component_swap_default_tests.rs b/crates/ui-egui/src/component_swap_default_tests.rs index 50b06b8b..c57f1fc2 100644 --- a/crates/ui-egui/src/component_swap_default_tests.rs +++ b/crates/ui-egui/src/component_swap_default_tests.rs @@ -16,7 +16,7 @@ fn id(v: serde_json::Value) -> NodeId { /// A selected "Button" component whose instance-swap property "Icon" defaults to "Heart", then 80 /// more components, the last "Zebra". Returns (ui, button, zebra). -fn setup() -> (Ui, NodeId, NodeId) { +pub(crate) fn setup() -> (Ui, NodeId, NodeId) { let mut s = Session::new(); let mut icon = |name: &str, k: usize| { let f = @@ -38,16 +38,16 @@ fn setup() -> (Ui, NodeId, NodeId) { (u, button, zebra) } -fn tree(u: &mut Ui) -> Vec { +pub(crate) fn tree(u: &mut Ui) -> Vec { u.frame_output(vec![]).platform_output.accesskit_update.unwrap().nodes.into_iter().map(|(_, n)| n).collect() } -fn centre(n: &Node) -> Pos2 { +pub(crate) fn centre(n: &Node) -> Pos2 { let b = n.bounds().unwrap(); pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32) } -fn click(u: &mut Ui, at: Pos2) { +pub(crate) fn click(u: &mut Ui, at: Pos2) { u.button(at, true); u.button(at, false); } @@ -78,7 +78,7 @@ fn swap_default(u: &Ui, button: NodeId) -> Option { } /// The default's dropdown on the "Icon" property row, scrolled into view in the Design panel. -fn default_combo(u: &mut Ui) -> Option { +pub(crate) fn default_combo(u: &mut Ui) -> Option { for _ in 0..20 { let combo = tree(u).into_iter().find(|n| n.role() == Role::ComboBox && n.label() == Some("Icon"))?; if centre(&combo).y < 860.0 { diff --git a/crates/ui-egui/src/component_swap_search_tests.rs b/crates/ui-egui/src/component_swap_search_tests.rs new file mode 100644 index 00000000..03893d20 --- /dev/null +++ b/crates/ui-egui/src/component_swap_search_tests.rs @@ -0,0 +1,69 @@ +//! The search at the top of an instance's swap property menu, driven through egui headless +//! (#1338): typing filters the components by name, an empty result says so, Escape clears the +//! search and then closes the menu, and Up, Down and Enter pick a match. + +use egui::{Event, Key, Modifiers, Pos2}; +use figcraft_doc::{NodeId, NodeKind}; +use serde_json::json; + +use crate::component_swap_default_tests::{centre, click, default_combo, setup, tree}; +use crate::layers_drag_tests::Ui; + +fn key(u: &mut Ui, key: Key) { + for pressed in [true, false] { + u.frame(vec![Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }]); + } +} + +fn typed(u: &mut Ui, text: &str) { + u.frame(vec![Event::Text(text.into())]); + u.frame(vec![]); +} + +/// Whether the menu shows a row or text `name` (one that wasn't on screen, at `before`, before it +/// opened). +fn shown(u: &mut Ui, before: &[Pos2], name: &str) -> bool { + tree(u).iter().any(|n| n.label().or(n.value()) == Some(name) && n.bounds().is_some() && !before.contains(¢re(n))) +} + +fn component(u: &Ui, name: &str) -> NodeId { + let doc = &u.app.session.ds().unwrap().doc; + doc.nodes().find(|n| n.name == name && matches!(n.kind, NodeKind::Component { .. })).unwrap().id +} + +#[test] +fn the_swap_menu_filters_as_you_type_and_picks_with_the_keyboard() { + let (mut u, button, _) = setup(); + let r = u.app.session.execute("instance.create", &json!({"component": button.0, "x": 900, "y": 0})).unwrap(); + let inst = r["id"].as_u64().unwrap(); + u.app.session.execute("select.set", &json!({"ids": [inst]})).unwrap(); + let combo = default_combo(&mut u).expect("the instance's Icon property has a dropdown"); + assert_eq!(combo.value(), Some("Heart")); + let before: Vec = tree(&mut u).iter().filter(|n| n.label().is_some()).map(centre).collect(); + // Open it: the search field has the keyboard, so typing filters straight away. + click(&mut u, centre(&combo)); + assert!(shown(&mut u, &before, "Glyph 1") && shown(&mut u, &before, "Zebra")); + typed(&mut u, "zEB"); + assert!(shown(&mut u, &before, "Zebra"), "a match, in any case"); + assert!(!shown(&mut u, &before, "Glyph 1"), "the rest are filtered out"); + typed(&mut u, "x"); + assert!(shown(&mut u, &before, "No matching components")); + assert!(!shown(&mut u, &before, "Zebra")); + // Escape clears the search; the menu stays open with everything again, still taking the keyboard. + key(&mut u, Key::Escape); + assert!(shown(&mut u, &before, "Glyph 1") && shown(&mut u, &before, "Zebra")); + // Down twice to the second match, Enter picks it and closes the menu. + typed(&mut u, "glyph 7"); + key(&mut u, Key::ArrowDown); + key(&mut u, Key::ArrowDown); + key(&mut u, Key::Enter); + let props = u.app.session.execute("component.properties", &json!({"id": inst})).unwrap(); + assert_eq!(props["properties"][0]["value"], json!(component(&u, "Glyph 70").0), "the second of Glyph 7, Glyph 70…"); + assert!(!shown(&mut u, &before, "Glyph 7"), "picking closes the menu"); + // With the search empty, Escape closes the menu. + let combo = default_combo(&mut u).unwrap(); + click(&mut u, centre(&combo)); + assert!(shown(&mut u, &before, "Glyph 1")); + key(&mut u, Key::Escape); + assert!(!shown(&mut u, &before, "Glyph 1"), "closed"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b72455ec..cef6515b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -63,6 +63,8 @@ mod component_props_reorder_tests; #[cfg(test)] mod component_swap_default_tests; #[cfg(test)] +mod component_swap_search_tests; +#[cfg(test)] mod context_menu_tests; #[cfg(test)] mod corner_individual_tests; diff --git a/crates/ui-egui/src/panels/component_panel.rs b/crates/ui-egui/src/panels/component_panel.rs index 0ab3e5cc..60e614d9 100644 --- a/crates/ui-egui/src/panels/component_panel.rs +++ b/crates/ui-egui/src/panels/component_panel.rs @@ -340,19 +340,10 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { let resp = widgets::dropdown(ui, &label, 84.0, None); crate::a11y::combo(&resp, k, &label, crate::a11y::menu_open(&resp)); // A file with many components (an icon library) scrolls instead of running off the screen. - menu_popup(&resp).width(200.0).show(|ui| { - egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { - for (c, name) in comps.iter() { - let current = c == default; - if menu_item(ui, &format!("{}{name}", if current { "✓ " } else { "" }), "") { - if !current { - app.run("component.editProperty", json!({"id": owner, "name": k, "default": c.0})); - } - ui.close(); - } - } - }); - }); + let all = || vec![(None, all_components(&comps))]; + if let Some(c) = component_menu(ui, &resp, 200.0, all, |c| c == *default).filter(|c| c != default) { + app.run("component.editProperty", json!({"id": owner, "name": k, "default": c.0})); + } } PropDef::Variant { .. } | PropDef::Slot { .. } => {} } @@ -590,24 +581,10 @@ fn instance_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node) { row(ui, 32.0, |ui| { let w = (ui.available_width() - 32.0).max(80.0); let resp = widgets::dropdown(ui, &comp_name, w, Some(Icon::Instance)).on_hover_text(tr("Swap instance")); - menu_popup(&resp).width(w.max(200.0)).show(|ui| { - egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { - let mut page = None; - for (c, name) in comps.iter() { - let pg = doc.page_of(*c); - if pg != page { - page = pg; - let pname = pg.and_then(|p| doc.node(p)).map(|p| p.name.clone()).unwrap_or_default(); - ui.label(egui::RichText::new(pname).font(semibold(11.0)).color(t.text_secondary)); - } - let current = Some(*c) == set || *c == *component; - if menu_item(ui, &format!("{}{name}", if current { "✓ " } else { "" }), "") && !current { - app.run("instance.swap", json!({"ids": [id], "component": c.0})); - ui.close(); - } - } - }); - }); + let current = |c: NodeId| Some(c) == set || c == *component; + if let Some(c) = component_menu(ui, &resp, w.max(200.0), || by_page(doc, &comps), current).filter(|c| !current(*c)) { + app.run("instance.swap", json!({"ids": [id], "component": c.0})); + } if missing { if icons::button(ui, Icon::Component, 24.0, false, tr("Restore main component")).clicked() { app.run("component.restoreMain", json!({"id": id})); @@ -717,7 +694,7 @@ fn instance_props(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node, comps }); } PropDef::InstanceSwap { preferred, .. } => { - if let Some(c) = swap_dropdown(ui, doc, figcraft_engine::params::id_of(&v), &preferred, comps, w, "").1 { + if let Some(c) = swap_dropdown(ui, doc, figcraft_engine::params::id_of(&v), &preferred, comps, w, &k).1 { app.run("instance.setProperties", json!({"ids": [id], "properties": {k.clone(): c.0}})); } } @@ -750,34 +727,162 @@ pub(crate) fn swap_dropdown( if !name.is_empty() { crate::a11y::combo(&resp, name, &label, crate::a11y::menu_open(&resp)); } + let groups = || { + let preferred: Vec<(NodeId, &str)> = preferred.iter().filter_map(|c| doc.node(*c).map(|n| (*c, n.name.as_str()))).collect(); + if preferred.is_empty() { + vec![(None, all_components(comps))] + } else { + vec![(Some(tr("Preferred")), preferred), (Some(tr("All components")), all_components(comps))] + } + }; + // Picking the current component picks nothing. + let picked = component_menu(ui, &resp, 200.0, groups, |c| cur == Some(c)).filter(|c| cur != Some(*c)); + (resp, picked) +} + +/// A component menu's groups: a heading (or none) and its components (id, name). +type CompGroups<'a> = Vec<(Option<&'a str>, Vec<(NodeId, &'a str)>)>; + +/// `comps` under their pages' names (they come page by page). +fn by_page<'a>(doc: &'a Document, comps: &'a [(NodeId, String)]) -> CompGroups<'a> { + let mut groups: CompGroups = Vec::new(); + let mut page = None; + for (c, name) in comps { + let pg = doc.page_of(*c); + if groups.is_empty() || pg != page { + page = pg; + groups.push((pg.and_then(|p| doc.node(p)).map(|p| p.name.as_str()), Vec::new())); + } + if let Some((_, cs)) = groups.last_mut() { + cs.push((*c, name.as_str())); + } + } + groups +} + +/// `comps` as one group's components. +fn all_components(comps: &[(NodeId, String)]) -> Vec<(NodeId, &str)> { + comps.iter().map(|(c, n)| (*c, n.as_str())).collect() +} + +/// The tallest a component menu is, its search field included; its list scrolls past that. +const COMP_MENU_HEIGHT: f32 = 360.0; + +/// What a component menu's search found this frame. +struct CompSearch<'a> { + /// The groups with the components whose names match (groups left empty dropped). + groups: CompGroups<'a>, + /// The match Up and Down are on (counted across the groups), and whether they moved it now. + hi: Option, + moved: bool, + /// The component Enter picked. + entered: Option, +} + +/// The menu `field` opens, at least `width` wide, listing `groups` (made only while it is open) +/// under a search field that takes the keyboard when the menu opens and filters the components by +/// name as you type. Up and Down step through the matches and Enter picks the one they are on (or +/// the first); Escape clears the search, then closes the menu. `checked` components are ticked. +/// The component picked. +fn component_menu<'a>( + ui: &Ui, + field: &egui::Response, + width: f32, + groups: impl FnOnce() -> CompGroups<'a>, + checked: impl Fn(NodeId) -> bool, +) -> Option { + let opened = field.clicked(); + craft_ui::combo::Menu::new(ui, field) + .height(COMP_MENU_HEIGHT) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .style(crate::right::menu_style) + .show_ui_with_header( + |ui| comp_search(ui, field.id, width, opened, groups()), + |ui, found| found.entered.or_else(|| comp_rows(ui, &found, &checked)), + ) + .flatten() +} + +/// A component menu's search field over `groups`, and what it matches. +fn comp_search<'a>(ui: &mut Ui, field: Id, width: f32, opened: bool, groups: CompGroups<'a>) -> CompSearch<'a> { + ui.set_min_width(width); + let state = field.with("comp_search"); + let (mut q, mut hi): (String, Option) = if opened { Default::default() } else { ui.data(|d| d.get_temp(state)).unwrap_or_default() }; + let before = q.clone(); + // Taken before the search field sees them (it would move its cursor, or give up the keyboard). + let (step, enter) = ui.input_mut(|i| { + if !q.is_empty() && i.consume_key(egui::Modifiers::NONE, egui::Key::Escape) { + q.clear(); + } + let down = i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown) as i64; + (down - i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp) as i64, i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)) + }); + let r = ui.add( + egui::TextEdit::singleline(&mut q) + .id(field.with("comp_query")) + // Escape clears the search (above) and keeps the keyboard here; with nothing to clear it closes the menu. + .event_filter(egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, tab: false, escape: true }) + .hint_text(tr("Search")) + .desired_width(ui.available_width()) + .font(crate::theme::regular(11.5)), + ); + widgets::select_all_on_focus(ui, &r, &q); + if opened { + r.request_focus(); + } + let ql = q.trim().to_lowercase(); + let groups: CompGroups<'a> = groups + .into_iter() + .map(|(h, cs)| (h, cs.into_iter().filter(|(_, n)| n.to_lowercase().contains(&ql)).collect::>())) + .filter(|(_, cs)| !cs.is_empty()) + .collect(); + let n = groups.iter().map(|(_, cs)| cs.len()).sum::(); + // A new search (or one cleared) starts again from the top. + if q != before || n == 0 { + hi = None; + } + let moved = step != 0 && n > 0; + if moved { + let at = hi.map_or(if step > 0 { -1 } else { n as i64 }, |h| h as i64) + step; + hi = usize::try_from(at.clamp(0, n as i64 - 1)).ok(); + } + let entered = enter.then(|| groups.iter().flat_map(|(_, cs)| cs).nth(hi.unwrap_or(0)).map(|(c, _)| *c)).flatten(); + if entered.is_some() { + ui.close(); + } + ui.data_mut(|d| d.insert_temp(state, (q, hi))); + CompSearch { groups, hi, moved, entered } +} + +/// A component menu's rows for what its search `found` (the one Up and Down are on lit); the +/// component clicked. +fn comp_rows(ui: &mut Ui, found: &CompSearch, checked: &impl Fn(NodeId) -> bool) -> Option { + let t = Tokens::get(ui.ctx()); + if found.groups.is_empty() { + widgets::label(ui, tr("No matching components"), t.text_tertiary); + return None; + } let mut picked = None; - menu_popup(&resp).width(200.0).show(|ui| { - let t = Tokens::get(ui.ctx()); - let heading = |ui: &mut Ui, text: &str| ui.label(egui::RichText::new(text).font(semibold(11.0)).color(t.text_secondary)); - let mut pick = |ui: &mut Ui, c: NodeId, name: &str| { - let current = cur == Some(c); - if menu_item(ui, &format!("{}{name}", if current { "✓ " } else { "" }), "") { - if !current { - picked = Some(c); - } + let mut k = 0; + for (heading, comps) in &found.groups { + if let Some(h) = heading { + ui.label(egui::RichText::new(*h).font(semibold(11.0)).color(t.text_secondary)); + } + for (c, name) in comps { + let lit = found.hi == Some(k); + k += 1; + let label = if checked(*c) { format!("✓ {name}") } else { (*name).to_string() }; + let resp = crate::right::menu_row_lit(ui, &label, lit); + if lit && found.moved { + resp.scroll_to_me(None); + } + if resp.clicked() { + picked = Some(*c); ui.close(); } - }; - let preferred: Vec<(NodeId, String)> = preferred.iter().filter_map(|c| doc.node(*c).map(|n| (*c, n.name.clone()))).collect(); - if !preferred.is_empty() { - heading(ui, tr("Preferred")); - for (c, name) in &preferred { - pick(ui, *c, name); - } - heading(ui, tr("All components")); } - egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { - for (c, name) in comps { - pick(ui, *c, name); - } - }); - }); - (resp, picked) + } + picked } fn prop_label(ui: &mut Ui, name: &str, icon: Icon) { diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 7c9329ea..e2439d40 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -34,7 +34,7 @@ pub(crate) use menu::menu_row_width; #[cfg(test)] pub(crate) use menu::preview_blend; pub use menu::{blend_menu, context_popup, menu_check, menu_item, menu_item_beside_checks, menu_item_with, menu_popup, menu_row}; -pub(crate) use menu::{block, menu_style, row, row_rect}; +pub(crate) use menu::{block, menu_row_lit, menu_style, row, row_rect}; use paints::{appearance, paints, variable_modes}; pub(crate) use stroke::{CAP_ART_W, cap_art, join_label}; diff --git a/crates/ui-egui/src/panels/right/menu.rs b/crates/ui-egui/src/panels/right/menu.rs index 704b1fe4..790c8a63 100644 --- a/crates/ui-egui/src/panels/right/menu.rs +++ b/crates/ui-egui/src/panels/right/menu.rs @@ -81,6 +81,16 @@ pub(crate) fn menu_row_width(label_w: f32, trail: f32) -> f32 { /// A menu row (see [`menu_item_with`]); its response tells hovering too. It spans the menu and /// is at least as wide as its label, shortcut and art. pub fn menu_row(ui: &mut Ui, label: &str, shortcut: &str, art_w: f32, art: impl FnOnce(&egui::Painter, Rect, Color32)) -> egui::Response { + lit_row(ui, label, shortcut, art_w, art, false) +} + +/// A plain menu row highlighted when `lit`, as if hovered: the row Up and Down are on in a menu +/// whose search field keeps the keyboard. +pub(crate) fn menu_row_lit(ui: &mut Ui, label: &str, lit: bool) -> egui::Response { + lit_row(ui, label, "", 0.0, |_, _, _| {}, lit) +} + +fn lit_row(ui: &mut Ui, label: &str, shortcut: &str, art_w: f32, art: impl FnOnce(&egui::Painter, Rect, Color32), lit: bool) -> egui::Response { let t = Tokens::get(ui.ctx()); let text = ui.painter().layout_no_wrap(label.to_owned(), regular(11.5), Color32::PLACEHOLDER); let keys = @@ -91,7 +101,7 @@ pub fn menu_row(ui: &mut Ui, label: &str, shortcut: &str, art_w: f32, art: impl a11y::menu_item(&resp, label, shortcut, None); // The keyboard's item is highlighted like the hovered one. a11y::own_focus(&resp); - let hovered = resp.hovered() || resp.has_focus(); + let hovered = lit || resp.hovered() || resp.has_focus(); if hovered { ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover); } diff --git a/docs/components-parity.md b/docs/components-parity.md index df48b9c4..e16fe916 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-10-26 · **Change:** minor (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-10-29 · **Change:** minor (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 @@ -21,7 +21,7 @@ accounts). | Switching variants keeps overrides where names match and both variants had the same value; text layers keep theirs | done | | | Default variant: the top-left one (new instances, Assets thumbnail, generated code) | done | | | Variant property order (names rewritten), duplicate combinations reported | done | drag a property in the Properties section to reorder it, one undo step (#679), or `component.editProperty {index}` | -| Properties: boolean, text, instance swap (preferred values), variant | done | every component menu scrolls past 320 pt, the default of an instance-swap property's too (#1338); no search in them yet | +| Properties: boolean, text, instance swap (preferred values), variant | done | every component menu scrolls past 320 pt, the default of an instance-swap property's too (#1338); the instance-swap menus (an instance's property and swap menus, a property's default, the playground) open with a search field that filters by name as you type, with Up, Down and Enter to pick and Escape to clear then close; the slot and preferred components menus have no search yet | | Slots (preferred components, fill menu, kept across updates) | done | Figma 2025 feature | | Exposed nested instances | done | hidden ones hide their properties | | Descriptions | done | | @@ -77,6 +77,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M3, #1338: the instance-swap property's menu (on an instance, its main component's default and the component playground) and the instance's swap menu open with a search field at the top that has the keyboard; typing filters the components by name (any case, headings without matches drop out), an empty result reads "No matching components", Up and Down step through the matches and Enter picks one (the first without a step), Escape clears the search and then closes the menu. Only the list scrolls; the menu grows back when the search is cleared. Tests: `component_swap_search_tests`. Numbers unchanged | | 2026-10-26 | minor | An instance-swap property's default menu scrolls (#1338): on the main component's Properties row the menu listed every component in the file at full height, so in a file with many (an icon library) the bottom ones ran off the screen and couldn't be picked. It now scrolls past 320 pt like the instance's swap menu, is at least 200 pt wide, ticks the current default and is named for assistive technology (`component_swap_default_tests`). Left: searching these menus. Numbers unchanged. | | 2026-10-24 | minor | Variables on a text style's fields from the style editor: the font and font style menus start with Apply variable (string variables), and the size, line height and letter spacing fields show a variable button on hover (number variables). A bound field shows its variable in place of the value; a click on it picks another variable and its detach button unbinds it (`style_picker_tests`). Left: the other effect and grid fields (commands only), library variables in the picker. Numbers unchanged. | | 2026-10-23 | minor | Instance-swap properties in the component playground: the Inspect tab's Playground shows a dropdown for each, with the same menu as the Design panel's (the property's preferred components first, then every component in the file). The component picked changes the preview and the code only, never the file. Left: slots in the playground. Numbers unchanged. | diff --git a/docs/roadmap.md b/docs/roadmap.md index a76d85fb..bae1acdf 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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-10-28 · **Change:** minor (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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M3, #1338: the instance-swap property's menu (on an instance, its main component's default and the component playground) and the instance's swap menu open with a search field at the top that has the keyboard; typing filters the components by name (any case, headings without matches drop out), an empty result reads "No matching components", Up and Down step through the matches and Enter picks one (the first without a step), Escape clears the search and then closes the menu. Only the list scrolls; the menu grows back when the search is cleared. Tests: `component_swap_search_tests`. Numbers unchanged | | 2026-10-29 | minor | A refused batch's fixes in linear time (#1063, from a security audit): the server restores each deleted layer once, walking each subtree once and stopping at layers already restored, and looks up and sends each style, variable, comment or document property fix once. The fixes sent back are unchanged. Deleting every layer of a 20,000-deep chain in a refused batch took 27 s on the hub thread in a debug build (each deleted layer's whole subtree walked again, each entry fix compared with all the earlier ones); it now takes 0.1 s (`a_refused_deep_delete_restores_each_node_once_in_linear_time`). | | 2026-10-29 | minor | M12, #1387: while a connector is dragged out with the connector tool, the canvas shows the connector itself as it will be made (its straight, elbow or curved route, line, weight and arrowhead, built by the same code as `board.connector`), not a thin line; the object under the pointer is outlined with its four attach points (the middles of its sides), the one the end goes to ringed; within 10 px of one the end keeps that side (`fromSide`/`toSide`, and `side` when re-attaching). Hovering with the tool shows the attach points too. Escape drops the preview; releasing makes the connector in one undo step. Tests: `board_connector_tests`. Numbers unchanged | | 2026-10-29 | minor | Large images on the canvas (#1483): an image shown smaller than its pixels draws on the canvas from a copy scaled down to 4096 or 8192 px on its longer side, the smallest that holds what the screen shows (`figcraft_render::images::shown`), cached beside the image itself in the same 512 MB budget. Two 16 000 × 10 000 images took 640 MB each and evicted each other on every frame that drew both; as copies they take 80 MB together zoomed out and 336 MB at 8192. Moving one over the other went from about 2.1 s a frame to 11 ms zoomed to fit and 15 ms at 30% in the app (debug build). Closer than that, and for tiles, the canvas draws from the image itself, so 100% and closer stay sharp; a view of both at 100% still takes about 2 s a frame, and an image with shadows still decodes in full for them. Exports, thumbnails and every other render are unchanged. Tests: `large_image_tests`. Numbers unchanged | From 72b4269383b8fbb1220bc976b7c8313edbbcdaab Mon Sep 17 00:00:00 2001 From: BFlat Date: Thu, 29 Oct 2026 19:15:19 +0700 Subject: [PATCH 19/63] M8: an AI agent attached over MCP comments under its own name (#1414) (#1529) Every command an MCP client runs acts as the client's name from initialize (clientInfo.title, else .name) with ' (agent)' after it, or 'AI agent' without one, carried to the app as engine.execute {agent}. Its messages, reactions and read state are its own, and its messages are marked (agent: true in the file and comment.list, an Agent label in the thread). The user's name and comments made in the app are unchanged; in live sessions the name and marker travel with the thread. --- ROADMAP.md | 3 +- crates/collab/src/diff_tests.rs | 5 +- crates/doc/src/comments.rs | 5 +- crates/engine/src/cmd/comment.rs | 47 +++++++++++--- crates/engine/src/comment_agent_tests.rs | 78 ++++++++++++++++++++++++ crates/engine/src/lib.rs | 5 ++ crates/engine/src/session/exec.rs | 10 +++ crates/engine/src/session/new.rs | 1 + crates/engine/src/sharing/live.rs | 11 +++- crates/mcp/src/headless.rs | 9 ++- crates/mcp/src/prompts.rs | 2 +- crates/mcp/src/server.rs | 59 +++++++++++++++++- crates/mcp/tests/it/agent_comments.rs | 50 +++++++++++++++ crates/mcp/tests/it/attached.rs | 15 +++++ crates/mcp/tests/it/main.rs | 2 + crates/ui-egui/locales/ar.toml | 1 + crates/ui-egui/locales/bn.toml | 1 + crates/ui-egui/locales/cs.toml | 1 + crates/ui-egui/locales/de.toml | 1 + crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/locales/fi.toml | 1 + crates/ui-egui/locales/fr.toml | 1 + crates/ui-egui/locales/id.toml | 1 + crates/ui-egui/locales/it.toml | 1 + crates/ui-egui/locales/ja.toml | 1 + crates/ui-egui/locales/ka.toml | 1 + crates/ui-egui/locales/ko.toml | 1 + crates/ui-egui/locales/nl.toml | 1 + crates/ui-egui/locales/pl.toml | 1 + crates/ui-egui/locales/pt-BR.toml | 1 + crates/ui-egui/locales/ru.toml | 1 + crates/ui-egui/locales/sv.toml | 1 + crates/ui-egui/locales/th.toml | 1 + crates/ui-egui/locales/tr.toml | 1 + crates/ui-egui/locales/vi.toml | 1 + crates/ui-egui/locales/zh-Hans.toml | 1 + crates/ui-egui/src/canvas/comments.rs | 15 ++++- crates/ui-egui/src/comments_tests.rs | 14 +++++ crates/ui-egui/src/control.rs | 2 +- docs/collaboration-parity.md | 5 +- docs/control-protocol.md | 5 ++ docs/mcp.md | 12 +++- docs/roadmap.md | 3 +- 44 files changed, 350 insertions(+), 30 deletions(-) create mode 100644 crates/engine/src/comment_agent_tests.rs create mode 100644 crates/mcp/tests/it/agent_comments.rs diff --git a/ROADMAP.md b/ROADMAP.md index 613e9353..fe4e6ea6 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-29 · **Change:** minor (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-10-29 · **Change:** minor (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 @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Comments from an AI agent (M8, #1414): an agent attached over MCP commented, reacted and read threads as the user (the name from Preferences) unless it passed `author` on every call. Every command an MCP client runs now acts as that client's name from `initialize` (`clientInfo.title`, else `.name`) with " (agent)" after it, or "AI agent" without one, carried to the app as `engine.execute {agent}`; its messages, reactions and read state are its own, and its messages are marked (`agent: true` in the file and in `comment.list`, an Agent label by the name in the thread). The user's name and the comments made in the app are unchanged, and in a live session the name and marker travel with the thread to everyone. Tests in `comment_agent_tests`, the MCP `agent_comments` and attached suites, the UI comments suite and the collab diff round trip. Numbers unchanged | | 2026-10-29 | minor | M3, #1338: the instance-swap property's menu (on an instance, its main component's default and the component playground) and the instance's swap menu open with a search field at the top that has the keyboard; typing filters the components by name (any case, headings without matches drop out), an empty result reads "No matching components", Up and Down step through the matches and Enter picks one (the first without a step), Escape clears the search and then closes the menu. Only the list scrolls; the menu grows back when the search is cleared. Tests: `component_swap_search_tests`. Numbers unchanged | | 2026-10-29 | minor | A refused batch's fixes in linear time (#1063, from a security audit): the server restores each deleted layer once, walking each subtree once and stopping at layers already restored, and looks up and sends each style, variable, comment or document property fix once. The fixes sent back are unchanged. Deleting every layer of a 20,000-deep chain in a refused batch took 27 s on the hub thread in a debug build (each deleted layer's whole subtree walked again, each entry fix compared with all the earlier ones); it now takes 0.1 s (`a_refused_deep_delete_restores_each_node_once_in_linear_time`). | | 2026-10-29 | minor | M12, #1387: while a connector is dragged out with the connector tool, the canvas shows the connector itself as it will be made (its straight, elbow or curved route, line, weight and arrowhead, built by the same code as `board.connector`), not a thin line; the object under the pointer is outlined with its four attach points (the middles of its sides), the one the end goes to ringed; within 10 px of one the end keeps that side (`fromSide`/`toSide`, and `side` when re-attaching). Hovering with the tool shows the attach points too. Escape drops the preview; releasing makes the connector in one undo step. Tests: `board_connector_tests`. Numbers unchanged | diff --git a/crates/collab/src/diff_tests.rs b/crates/collab/src/diff_tests.rs index 6141be22..ab3d5be3 100644 --- a/crates/collab/src/diff_tests.rs +++ b/crates/collab/src/diff_tests.rs @@ -131,9 +131,10 @@ fn document_level_data_round_trips() { x: 1.0, y: 2.0, anchor: None, - first: Message { author: "Ada".into(), text: "Hi".into(), created: 1, edited: 0, reactions: vec![] }, + first: Message { author: "Ada".into(), text: "Hi".into(), created: 1, edited: 0, reactions: vec![], agent: false }, resolved: false, - replies: vec![], + // An AI agent's reply reaches the others with its name and marker. + replies: vec![Message { agent: true, ..Message::new("Claude (agent)", "Done", 2) }], read_by: None, }); b.images.insert("hash".into(), ImageData { bytes: Arc::new(vec![1, 2, 3, 250]), width: 2, height: 1 }); diff --git a/crates/doc/src/comments.rs b/crates/doc/src/comments.rs index 53edc9c9..c350069b 100644 --- a/crates/doc/src/comments.rs +++ b/crates/doc/src/comments.rs @@ -26,6 +26,9 @@ pub struct Message { pub edited: u64, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub reactions: Vec, + /// Written by an AI agent (a command run for an MCP client), not by a person in the app. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub agent: bool, } /// A reply in a comment thread. @@ -37,7 +40,7 @@ fn is_zero(v: &u64) -> bool { impl Message { pub fn new(author: impl Into, text: impl Into, created: u64) -> Self { - Self { author: author.into(), text: text.into(), created, edited: 0, reactions: Vec::new() } + Self { author: author.into(), text: text.into(), created, edited: 0, reactions: Vec::new(), agent: false } } /// `user` left the `emoji` reaction. diff --git a/crates/engine/src/cmd/comment.rs b/crates/engine/src/cmd/comment.rs index baac9ab3..5666d3d1 100644 --- a/crates/engine/src/cmd/comment.rs +++ b/crates/engine/src/cmd/comment.rs @@ -1,8 +1,9 @@ //! Comments: add, reply, edit, react, resolve, read state, delete, find and reveal comment threads. //! //! Messages are addressed by `reply` (an index into the replies; omitted: the thread's first -//! message). `author` is who is acting (default: the name from Preferences, `pref.userName`): only -//! a message's author edits or deletes it, and only the thread's author deletes the thread. +//! message). `author` is who is acting (default: the AI agent the command runs for, else the name +//! from Preferences, `pref.userName`): only a message's author edits or deletes it, and only the +//! thread's author deletes the thread. use std::fmt::Write as _; @@ -16,6 +17,12 @@ use crate::{Session, bad, spec}; /// The name comments are signed with until the user sets theirs. pub const DEFAULT_AUTHOR: &str = "You"; +/// The name of an AI agent that gave none. +pub const DEFAULT_AGENT: &str = "AI agent"; + +/// Most characters of an agent's own name in its comments. +const MAX_AGENT_NAME: usize = 64; + /// Links to a thread: `figcraft://comment/`. const LINK_PREFIX: &str = "figcraft://comment/"; @@ -260,9 +267,27 @@ pub fn user_name(s: &Session) -> String { if n.is_empty() { DEFAULT_AUTHOR.into() } else { n.to_string() } } -/// Who is acting: `author` from the params, else the user. +/// How an AI agent's comments are signed: its client's name, marked so it never reads as a +/// person's ("Claude" → "Claude (agent)"), or [`DEFAULT_AGENT`] when it gave none. +pub fn agent_author(name: &str) -> String { + let n: String = name.chars().filter(|c| !c.is_control()).collect(); + let n: String = n.trim().chars().take(MAX_AGENT_NAME).collect(); + if n.is_empty() { DEFAULT_AGENT.into() } else { format!("{n} (agent)") } +} + +/// Who acts when no `author` is given: the AI agent the command runs for, else the user. +pub fn acting_name(s: &Session) -> String { + s.agent.clone().unwrap_or_else(|| user_name(s)) +} + +/// Who is acting: `author` from the params, else [`acting_name`]. fn author(s: &Session, p: &Value) -> String { - string(p, "author").map(|a| a.trim().to_string()).filter(|a| !a.is_empty()).unwrap_or_else(|| user_name(s)) + string(p, "author").map(|a| a.trim().to_string()).filter(|a| !a.is_empty()).unwrap_or_else(|| acting_name(s)) +} + +/// A new message by `who`, marked as an agent's when the command runs for one. +fn new_message(s: &Session, who: String, text: String) -> Message { + Message { agent: s.agent.is_some(), ..Message::new(who, text, now_secs()) } } fn text(cmd: &str, p: &Value) -> crate::Result { @@ -303,7 +328,7 @@ fn local(doc: &Document, anchor: Option, x: f64, y: f64) -> (f64, f64) { fn message_json(m: &Message) -> Value { json!({ - "author": m.author, "text": m.text, "created": m.created, "edited": m.edited, + "author": m.author, "agent": m.agent, "text": m.text, "created": m.created, "edited": m.edited, "reactions": m.reactions.iter().map(|r| json!({"emoji": r.emoji, "users": r.users})).collect::>(), }) } @@ -338,7 +363,7 @@ fn comment_brief_json(doc: &Document, c: &Comment, user: &str) -> Value { "x": x, "y": y, "anchor": c.anchor, "frame": frame, "frameName": frame.and_then(|f| doc.node(f)).map(|n| clip_chars(&n.name, 80)), "resolved": c.resolved, "unread": c.is_unread(user), "link": link(c.id), - "author": clip_chars(&c.first.author, 80), "preview": clip_chars(&c.first.text, 160), + "author": clip_chars(&c.first.author, 80), "agent": c.first.agent, "preview": clip_chars(&c.first.text, 160), "replies": c.replies.len(), }) } @@ -565,7 +590,7 @@ pub fn specs() -> Vec { x: lx, y: ly, anchor, - first: Message::new(who.clone(), text, now_secs()), + first: new_message(s, who.clone(), text), resolved: false, replies: Vec::new(), read_by: None, @@ -579,7 +604,7 @@ pub fn specs() -> Vec { spec!("comment.reply", "Reply to comment", "{id | link, text, author?}", true, |s, p| { let id = req_comment("comment.reply", s, p)?; let who = author(s, p); - let r = Message::new(who.clone(), text("comment.reply", p)?, now_secs()); + let r = new_message(s, who.clone(), text("comment.reply", p)?); let c = s.doc_mut()?.comment_mut(id).ok_or_else(|| bad("comment.reply", "no comment"))?; c.replies.push(r); c.resolved = false; @@ -758,7 +783,9 @@ pub fn specs() -> Vec { let id = req_comment("comment.link", s, p)?; Ok(json!({"link": link(id)})) }), - spec!("comment.people", "People in comments", "{}", false, |s, _| { Ok(json!({"people": s.doc()?.comment_people(), "you": user_name(s)})) }), + spec!("comment.people", "People in comments", "{}", false, |s, _| { + Ok(json!({"people": s.doc()?.comment_people(), "you": acting_name(s)})) + }), spec!( "comment.list", "List comments", @@ -817,7 +844,7 @@ pub fn specs() -> Vec { } Some(_) => return Err(bad("comment.prompt", "`ids` must be an array of comment ids")), }; - let who = user_name(s); + let who = acting_name(s); let selected: Vec<&Comment> = if let Some(ids) = ids { let mut out = Vec::with_capacity(ids.len()); for id in ids { diff --git a/crates/engine/src/comment_agent_tests.rs b/crates/engine/src/comment_agent_tests.rs new file mode 100644 index 00000000..f5a5b659 --- /dev/null +++ b/crates/engine/src/comment_agent_tests.rs @@ -0,0 +1,78 @@ +//! Comments from an AI agent (#1414): a command run as an agent (`Session::execute_as`, which the +//! control channel's and MCP's `engine.execute {agent}` use) signs comments with the agent's own +//! name and marks them as an agent's; the user's own comments stay as they were. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::cmd::comment::agent_author; +use crate::live::initials; + +fn as_agent(s: &mut Session, agent: &str, cmd: &str, p: Value) -> Value { + s.execute_as(Some(agent), cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn thread(s: &mut Session, agent: Option<&str>) -> Value { + s.execute_as(agent, "comment.list", &json!({"filter": "all"})).unwrap()["comments"][0].clone() +} + +#[test] +fn an_agents_comments_carry_its_name_and_the_agent_marker() { + let mut s = Session::new(); + let c = s.execute("comment.add", &json!({"x": 10, "y": 10, "text": "@Claude make this bigger"})).unwrap()["id"].as_u64().unwrap(); + as_agent(&mut s, "Claude", "comment.reply", json!({"id": c, "text": "Done"})); + let t = thread(&mut s, None); + assert_eq!((t["author"].as_str(), t["agent"].as_bool()), (Some("You"), Some(false)), "the user's own comment is unchanged: {t}"); + let r = &t["replies"][0]; + assert_eq!((r["author"].as_str(), r["agent"].as_bool()), (Some("Claude (agent)"), Some(true)), "{r}"); + let doc = s.doc().unwrap(); + let m = &doc.comment(c).unwrap().replies[0]; + assert!(m.agent && m.author == "Claude (agent)"); + // The agent acts as itself again later: no `author` on any call. + as_agent(&mut s, "Claude", "comment.react", json!({"id": c, "emoji": "👍"})); + assert_eq!(s.doc().unwrap().comment(c).unwrap().first.reactions[0].users, ["Claude (agent)"]); + assert_eq!(as_agent(&mut s, "Claude", "comment.people", json!({}))["you"], "Claude (agent)"); + assert_eq!(s.execute("comment.people", &json!({})).unwrap()["you"], "You", "the user's name never changes"); + assert_eq!(s.agent, None, "the agent acts only for its own call"); +} + +#[test] +fn an_agents_read_state_is_its_own() { + let mut s = Session::new(); + let c = s.execute("comment.add", &json!({"x": 10, "y": 10, "text": "Look"})).unwrap()["id"].as_u64().unwrap(); + as_agent(&mut s, "Claude", "comment.reply", json!({"id": c, "text": "On it"})); + assert_eq!(thread(&mut s, None)["unread"], true, "the agent's reply is unread for the user"); + as_agent(&mut s, "Claude", "comment.markRead", json!({})); + assert_eq!(thread(&mut s, None)["unread"], true, "the agent reading it doesn't read it for the user"); + assert_eq!(thread(&mut s, Some("Claude"))["unread"], false); +} + +#[test] +fn an_agent_without_a_name_is_still_not_the_user() { + let mut s = Session::new(); + as_agent(&mut s, "", "comment.add", json!({"x": 0, "y": 0, "text": "Hi"})); + let t = thread(&mut s, None); + assert_eq!((t["author"].as_str(), t["agent"].as_bool()), (Some("AI agent"), Some(true)), "{t}"); + // An explicit `author` still wins, and the message is still an agent's. + as_agent(&mut s, "Claude", "comment.reply", json!({"id": t["id"], "text": "x", "author": "Helper"})); + let r = &thread(&mut s, None)["replies"][0]; + assert_eq!((r["author"].as_str(), r["agent"].as_bool()), (Some("Helper"), Some(true)), "{r}"); +} + +#[test] +fn agent_names_are_cleaned() { + assert_eq!(agent_author(" Claude "), "Claude (agent)"); + assert_eq!(agent_author("\u{7}\n "), "AI agent"); + assert_eq!(agent_author("a\u{0}b"), "ab (agent)"); + let long = "x".repeat(500); + assert_eq!(agent_author(&long).chars().count(), 64 + " (agent)".len()); +} + +#[test] +fn an_agents_avatar_shows_its_names_initial() { + assert_eq!(initials("Claude (agent)"), "C"); + assert_eq!(initials("AI agent"), "AA"); + assert_eq!(initials("Ada Lovelace"), "AL"); + assert_eq!(initials("(x) [y]"), "([", "no word starts with a letter: as before"); + assert_eq!(initials(" "), "?"); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d36f1905..0b801589 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -148,6 +148,9 @@ pub struct Session { pub events: Arc, /// Events recorded inside a group: (kind, document, path, data). pending_events: Vec<(String, String, Option, Value)>, + /// The AI agent the running command is for ([`Session::execute_as`]), as comments are signed + /// (`"Claude (agent)"`); `None` for the user. + pub agent: Option, } impl Default for Session { @@ -219,6 +222,8 @@ mod collab_tests; #[cfg(test)] mod collab_undo_tests; #[cfg(test)] +mod comment_agent_tests; +#[cfg(test)] mod comments_polish_tests; #[cfg(test)] mod component_codegen_tests; diff --git a/crates/engine/src/session/exec.rs b/crates/engine/src/session/exec.rs index a9d491e2..633bf9db 100644 --- a/crates/engine/src/session/exec.rs +++ b/crates/engine/src/session/exec.rs @@ -45,6 +45,16 @@ impl Session { } } + /// [`Session::execute`] for an AI agent named `agent` (the client's name; empty when it gave + /// none), or for the user (`None`): the comments it makes are signed with the agent's name and + /// marked as an agent's. + pub fn execute_as(&mut self, agent: Option<&str>, id: &str, params: &Value) -> Result { + let was = std::mem::replace(&mut self.agent, agent.map(cmd::comment::agent_author)); + let r = self.execute(id, params); + self.agent = was; + r + } + /// Run a command by id. Mutations become one undo step; a failure or panic leaves the document /// as it was. pub fn execute(&mut self, id: &str, params: &Value) -> Result { diff --git a/crates/engine/src/session/new.rs b/crates/engine/src/session/new.rs index 677e2471..6e86c7c7 100644 --- a/crates/engine/src/session/new.rs +++ b/crates/engine/src/session/new.rs @@ -30,6 +30,7 @@ impl Session { background: background::Background::default(), events: Arc::new(events::EventLog::default()), pending_events: Vec::new(), + agent: None, } } diff --git a/crates/engine/src/sharing/live.rs b/crates/engine/src/sharing/live.rs index a469b28e..c58d805a 100644 --- a/crates/engine/src/sharing/live.rs +++ b/crates/engine/src/sharing/live.rs @@ -343,9 +343,16 @@ pub fn display_name(prefs: &crate::Prefs) -> String { if n == crate::cmd::comment::DEFAULT_AUTHOR { String::new() } else { n.chars().filter(|c| !c.is_control()).take(MAX_NAME).collect() } } -/// Initials for an avatar ("Ada Lovelace" → "AL"; "?" for none). +/// Initials for an avatar ("Ada Lovelace" → "AL"; "Claude (agent)" → "C": words that start with a +/// letter or digit, when there are any; "?" for none). pub fn initials(name: &str) -> String { - let mut out: String = name.split_whitespace().filter_map(|w| w.chars().next()).take(2).collect(); + let firsts = |alnum: bool| -> String { + name.split_whitespace().filter_map(|w| w.chars().next()).filter(|c| !alnum || c.is_alphanumeric()).take(2).collect() + }; + let mut out = firsts(true); + if out.is_empty() { + out = firsts(false); + } if out.is_empty() { out.push('?'); } diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 3a76064b..c0f948e3 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -55,9 +55,14 @@ impl Headless { } fn execute(&mut self, cmd: &str, params: &Value) -> Result { + self.execute_as(None, cmd, params) + } + + /// A command for `agent` (an AI agent's name, `engine.execute {agent}`) or for the user. + fn execute_as(&mut self, agent: Option<&str>, cmd: &str, params: &Value) -> Result { // What happened in a live session since the last call (latest presence only). self.session.live_poll(); - let r = self.session.execute(cmd, params).map_err(|e| e.to_string()); + let r = self.session.execute_as(agent, cmd, params).map_err(|e| e.to_string()); self.session.live_sync(); // No UI: dialogs and panels requested by commands are dropped. self.session.ui_requests.clear(); @@ -243,7 +248,7 @@ impl Backend for Headless { None | Some(Value::Null) => json!({}), Some(v) => v.clone(), }; - self.execute(&cmd, ¶ms) + self.execute_as(p.get("agent").and_then(Value::as_str), &cmd, ¶ms) } "engine.commands" => Ok(Value::Array( figcraft_engine::all_commands().iter().map(|c| json!({"id": c.id, "label": c.label, "params": c.params, "menu": c.menu(), "shortcut": c.shortcut(), "undoable": c.history})).collect(), diff --git a/crates/mcp/src/prompts.rs b/crates/mcp/src/prompts.rs index 5cf63c13..060f7a30 100644 --- a/crates/mcp/src/prompts.rs +++ b/crates/mcp/src/prompts.rs @@ -104,7 +104,7 @@ pub const PROMPTS: &[PromptDef] = &[ template: r#"Watch for new feedback in this FigCraft document for {minutes} minutes. 1. Start the watcher: wait_for_changes {"after": 0, "timeoutMs": 0, "topics": ["comments"]}. Keep its `cursor` and note `reset`; if the returned cursor is 0, remember that the initial cursor was zero. -2. Handle what is already open: run_command {"command": "comment.list", "params": {"page": "all", "filter": "open"}}. For each thread: inspect the anchor and frame, make the smallest change (one batch per thread), comment.reply with what changed (author "AI"), verify, then comment.resolve. Leave ambiguous threads open with a question. +2. Handle what is already open: run_command {"command": "comment.list", "params": {"page": "all", "filter": "open"}}. For each thread: inspect the anchor and frame, make the smallest change (one batch per thread), comment.reply with what changed (it is signed with your own agent name), verify, then comment.resolve. Leave ambiguous threads open with a question. 3. Then loop until the budget ends: wait_for_changes {"after": , "timeoutMs": 20000, "topics": ["comments"]}. Set cursor to the result's cursor. For each comment.added or comment.replied event, read the thread with comment.list. If its latest message is your own reply, skip it (comment.reply emits comment.replied too); otherwise fix it the same way. If reset is true, dropped is greater than 0, or the cursor was 0 before this poll and the returned cursor is greater than 0, re-read every open thread and continue with the returned cursor. 4. Stop after {minutes} minutes; summarize the threads you fixed and the ones still open (and why)."#, defaults: &[("minutes", "5")], diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index cd4c7177..1a25ac7f 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -52,6 +52,46 @@ pub struct Server { /// The automation roots every request's file access is confined to (`--automation-read-root`, /// `--automation-write-root`), if any. roots: Option>, + /// The client's name from `initialize` (`clientInfo.title`, else `.name`; empty until then): + /// every command runs as this AI agent, so its comments are signed with it, not the user's name. + agent: String, +} + +/// The backend as the MCP client's AI agent: `engine.execute` carries its name (`agent`). +struct AsAgent<'a> { + inner: &'a mut dyn Backend, + agent: &'a str, +} + +impl Backend for AsAgent<'_> { + fn call(&mut self, method: &str, mut params: Value) -> Result { + if method == "engine.execute" + && let Some(p) = params.as_object_mut() + { + p.insert("agent".into(), json!(self.agent)); + } + self.inner.call(method, params) + } + + fn has_ui(&self) -> bool { + self.inner.has_ui() + } + + fn describe(&self) -> String { + self.inner.describe() + } + + fn render_png(&mut self, ids: &[u64], scale: f64) -> Result, String> { + self.inner.render_png(ids, scale) + } + + fn screenshot_png(&mut self) -> Result, String> { + self.inner.screenshot_png() + } + + fn window_png(&mut self, window: u64) -> Result, String> { + self.inner.window_png(window) + } } fn response(id: Value, result: Value) -> Value { @@ -62,13 +102,20 @@ fn error(id: Value, code: i64, message: impl Into) -> Value { json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message.into()}}) } +/// The name an `initialize` request gives its client (`clientInfo.title`, else `.name`; empty +/// without one). The engine keeps 64 characters of it; this only bounds what each call carries. +fn client_name(params: &Value) -> String { + let name = |k: &str| params.get("clientInfo").and_then(|i| i.get(k)).and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()); + name("title").or_else(|| name("name")).unwrap_or_default().chars().take(256).collect() +} + fn panic_message(payload: &(dyn std::any::Any + Send)) -> String { payload.downcast_ref::<&str>().map(ToString::to_string).or_else(|| payload.downcast_ref::().cloned()).unwrap_or_else(|| "panic".into()) } impl Server { pub fn new(backend: Box) -> Self { - Self { backend, initialized: false, roots: None } + Self { backend, initialized: false, roots: None, agent: String::new() } } /// Confine the files every request reads and writes to `roots` (see @@ -192,9 +239,15 @@ impl Server { } } + /// The backend, acting as the client's agent. + fn as_agent(&mut self) -> AsAgent<'_> { + AsAgent { inner: self.backend.as_mut(), agent: &self.agent } + } + fn request(&mut self, method: &str, params: &Value) -> Result { match method { "initialize" => { + self.agent = client_name(params); let asked = params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL_VERSION); let version = if SUPPORTED_VERSIONS.contains(&asked) { asked } else { PROTOCOL_VERSION }; Ok(json!({ @@ -219,13 +272,13 @@ impl Server { if !crate::tools::TOOL_NAMES.contains(&name) { return Err((INVALID_PARAMS, format!("unknown tool `{name}`"))); } - Ok(call_tool(self.backend.as_mut(), name, &args).to_value()) + Ok(call_tool(&mut self.as_agent(), name, &args).to_value()) } "resources/list" => Ok(resources::list()), "resources/templates/list" => Ok(resources::templates()), "resources/read" => { let uri = params.get("uri").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing `uri` (a string)".to_string()))?; - let v = resources::read(self.backend.as_mut(), uri).map_err(|e| match e { + let v = resources::read(&mut self.as_agent(), uri).map_err(|e| match e { resources::ReadError::NotFound(uri) => (RESOURCE_NOT_FOUND, format!("resource not found: {uri}")), resources::ReadError::Backend(why) => (INTERNAL_ERROR, why), })?; diff --git a/crates/mcp/tests/it/agent_comments.rs b/crates/mcp/tests/it/agent_comments.rs new file mode 100644 index 00000000..fe87bf94 --- /dev/null +++ b/crates/mcp/tests/it/agent_comments.rs @@ -0,0 +1,50 @@ +//! An agent attached over MCP comments under its own name (#1414): the client's name from +//! `initialize` (`clientInfo.title`, else `.name`), marked as an agent's, or "AI agent". + +use figcraft_mcp::Server; +use serde_json::{Value, json}; + +use crate::common::Client; + +fn rpc(s: &mut Server, method: &str, params: Value) -> Value { + let line = json!({"jsonrpc": "2.0", "id": 1, "method": method, "params": params}).to_string(); + serde_json::from_str(&s.handle_line(&line).unwrap()).unwrap() +} + +fn run(s: &mut Server, command: &str, params: Value) -> Value { + let r = rpc(s, "tools/call", json!({"name": "run_command", "arguments": {"command": command, "params": params}})); + assert_eq!(r["result"]["isError"], false, "{r}"); + serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap() +} + +/// The first message's (author, agent marker) after the server comments once. +fn signed(s: &mut Server) -> (String, Value) { + run(s, "comment.add", json!({"x": 10, "y": 10, "text": "Made it bigger"})); + let t = run(s, "comment.list", json!({"filter": "all"}))["comments"][0].clone(); + (t["author"].as_str().unwrap().to_string(), t["agent"].clone()) +} + +#[test] +fn an_agent_comments_under_its_clients_name() { + let mut s = Server::headless(); + rpc( + &mut s, + "initialize", + json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "claude-code", "title": "Claude", "version": "1"}}), + ); + assert_eq!(signed(&mut s), ("Claude (agent)".into(), json!(true)), "the title wins over the name"); + let mut c = Client::new(); + c.run("comment.add", json!({"x": 0, "y": 0, "text": "Hi"})); + let t = c.run("comment.list", json!({}))["comments"][0].clone(); + assert_eq!((t["author"].clone(), t["agent"].clone()), (json!("test (agent)"), json!(true)), "the name without a title"); + assert_eq!(c.run("comment.people", json!({}))["you"], "test (agent)", "the agent is itself on every call"); +} + +#[test] +fn an_agent_without_a_name_is_still_an_agent() { + let mut s = Server::headless(); + assert_eq!(signed(&mut s), ("AI agent".into(), json!(true)), "no initialize"); + let mut s = Server::headless(); + rpc(&mut s, "initialize", json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": " ", "version": "1"}})); + assert_eq!(signed(&mut s), ("AI agent".into(), json!(true)), "a blank name"); +} diff --git a/crates/mcp/tests/it/attached.rs b/crates/mcp/tests/it/attached.rs index f1343dd2..2b9e7dc5 100644 --- a/crates/mcp/tests/it/attached.rs +++ b/crates/mcp/tests/it/attached.rs @@ -64,6 +64,21 @@ fn attached_backend_forwards_everything() { assert_eq!(err["isError"], true); } +#[test] +fn an_attached_agent_comments_under_its_own_name() { + let mut s = Server::new(Box::new(Attached::port(fake_app()).unwrap())); + let init = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "Claude", "version": "1"}}}); + assert!(s.handle_line(&init.to_string()).is_some()); + let run = |s: &mut Server, command: &str, params: Value| { + let r = call(s, "run_command", json!({"command": command, "params": params})); + assert_eq!(r["isError"], false, "{r}"); + serde_json::from_str::(r["content"][0]["text"].as_str().unwrap()).unwrap() + }; + run(&mut s, "comment.add", json!({"x": 10, "y": 10, "text": "Done"})); + let t = run(&mut s, "comment.list", json!({}))["comments"][0].clone(); + assert_eq!((t["author"].clone(), t["agent"].clone()), (json!("Claude (agent)"), json!(true)), "{t}"); +} + #[test] fn attached_to_nothing_fails_cleanly() { let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port(); diff --git a/crates/mcp/tests/it/main.rs b/crates/mcp/tests/it/main.rs index 23d23ec9..5233cd7f 100644 --- a/crates/mcp/tests/it/main.rs +++ b/crates/mcp/tests/it/main.rs @@ -1,5 +1,7 @@ //! The crate's integration tests, built as one test binary. +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod agent_comments; #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod agent_tasks; #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] diff --git a/crates/ui-egui/locales/ar.toml b/crates/ui-egui/locales/ar.toml index a00004dc..a8f114fa 100644 --- a/crates/ui-egui/locales/ar.toml +++ b/crates/ui-egui/locales/ar.toml @@ -84,6 +84,7 @@ status = "draft — needs native review" "Advanced stroke" = "إعدادات متقدمة للحد الخارجي" "After delay" = "بعد تأخير" "After delay {ms}ms" = "بعد تأخير {ms} مللي ثانية" +"Agent" = "وكيل" "Align" = "محاذاة" "Align bottom" = "محاذاة لأسفل" "Align center" = "محاذاة للوسط أفقيًا" diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml index b7d74e60..be71f7a6 100644 --- a/crates/ui-egui/locales/bn.toml +++ b/crates/ui-egui/locales/bn.toml @@ -79,6 +79,7 @@ status = "draft — needs native review" "Advanced stroke" = "উন্নত স্ট্রোক" "After delay" = "বিলম্বের পর" "After delay {ms}ms" = "{ms}ms বিলম্বের পর" +"Agent" = "এজেন্ট" "Align" = "সারিবদ্ধ করুন" "Align bottom" = "নিচে সারিবদ্ধ করুন" "Align center" = "কেন্দ্রে সারিবদ্ধ করুন" diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 04b3001d..c6158f80 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -74,6 +74,7 @@ status = "draft — needs native review" "Advanced stroke" = "Pokročilé nastavení tahu" "After delay" = "Po prodlevě" "After delay {ms}ms" = "Po prodlevě {ms} ms" +"Agent" = "Agent" "Align" = "Zarovnat" "Align bottom" = "Zarovnat dolů" "Align center" = "Zarovnat na střed" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index fa5a02d8..83a5ca3c 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -76,6 +76,7 @@ status = "draft — needs native review" "Advanced stroke" = "Erweiterte Kontur" "After delay" = "Nach Verzögerung" "After delay {ms}ms" = "Nach {ms} ms" +"Agent" = "Agent" "Align" = "Ausrichtung" "Align bottom" = "Unten ausrichten" "Align center" = "Zentriert ausrichten" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index b329884c..3450db8e 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -78,6 +78,7 @@ name = "English" "Advanced stroke" = "Advanced stroke" "After delay" = "After delay" "After delay {ms}ms" = "After delay {ms}ms" +"Agent" = "Agent" "Align" = "Align" "Align bottom" = "Align bottom" "Align center" = "Align center" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 844dbb27..9cc288d3 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -101,6 +101,7 @@ status = "draft — needs native review" "Advanced stroke" = "Trazo avanzado" "After delay" = "Tras un retardo" "After delay {ms}ms" = "Tras {ms}ms" +"Agent" = "Agente" "Align" = "Alineación" "Align bottom" = "Alinear abajo" "Align center" = "Alinear al centro" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index 55b094d0..ef5c3c94 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -80,6 +80,7 @@ status = "draft — needs native review" "Advanced stroke" = "Reunaviivan lisäasetukset" "After delay" = "Viiveen jälkeen" "After delay {ms}ms" = "Viiveen {ms}ms jälkeen" +"Agent" = "Agentti" "Align" = "Tasaa" "Align bottom" = "Tasaa alas" "Align center" = "Keskitä" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 2d9b9b76..b800e0f5 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -79,6 +79,7 @@ status = "draft — needs native review" "Advanced stroke" = "Contour avancé" "After delay" = "Après un délai" "After delay {ms}ms" = "Après {ms} ms" +"Agent" = "Agent" "Align" = "Alignement" "Align bottom" = "Aligner en bas" "Align center" = "Aligner au centre" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index 172043f5..76206f48 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -81,6 +81,7 @@ status = "draft — needs native review" "Advanced stroke" = "Stroke lanjutan" "After delay" = "Setelah jeda" "After delay {ms}ms" = "Setelah jeda {ms}ms" +"Agent" = "Agen" "Align" = "Ratakan" "Align bottom" = "Ratakan bawah" "Align center" = "Rata tengah" diff --git a/crates/ui-egui/locales/it.toml b/crates/ui-egui/locales/it.toml index 0a9c6156..c82ee628 100644 --- a/crates/ui-egui/locales/it.toml +++ b/crates/ui-egui/locales/it.toml @@ -74,6 +74,7 @@ status = "draft — needs native review" "Advanced stroke" = "Contorno avanzato" "After delay" = "Dopo un ritardo" "After delay {ms}ms" = "Dopo un ritardo di {ms} ms" +"Agent" = "Agente" "Align" = "Allinea" "Align bottom" = "Allinea in basso" "Align center" = "Allinea al centro" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 417311b2..244865e6 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -80,6 +80,7 @@ status = "draft — needs native review" "Advanced stroke" = "詳細な線" "After delay" = "遅延後" "After delay {ms}ms" = "{ms}ms 遅延後" +"Agent" = "エージェント" "Align" = "整列" "Align bottom" = "下揃え" "Align center" = "中央揃え" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index 84597419..cc21e9ff 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -81,6 +81,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Advanced stroke" = "კონტურის დამატებითი პარამეტრები" "After delay" = "დაყოვნების შემდეგ" "After delay {ms}ms" = "დაყოვნების შემდეგ: {ms} მწმ" +"Agent" = "აგენტი" "Align" = "სწორება" "Align bottom" = "ქვემოთ სწორება" "Align center" = "ცენტრში სწორება" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 693cf5b6..d10dbdc3 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -76,6 +76,7 @@ status = "draft — needs native review" "Advanced stroke" = "고급 선" "After delay" = "지연 후" "After delay {ms}ms" = "{ms}ms 지연 후" +"Agent" = "에이전트" "Align" = "정렬" "Align bottom" = "아래쪽 정렬" "Align center" = "가운데 정렬" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index 63708f82..0dd2a9ae 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -80,6 +80,7 @@ status = "draft — needs native review" "Advanced stroke" = "Geavanceerde lijn" "After delay" = "Na vertraging" "After delay {ms}ms" = "Na {ms}ms vertraging" +"Agent" = "Agent" "Align" = "Uitlijnen" "Align bottom" = "Onderaan uitlijnen" "Align center" = "Midden uitlijnen" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index d5402774..46c951f8 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -78,6 +78,7 @@ name = "Polski" "Advanced stroke" = "Zaawansowany obrys" "After delay" = "Po opóźnieniu" "After delay {ms}ms" = "Po opóźnieniu {ms} ms" +"Agent" = "Agent" "Align" = "Wyrównaj" "Align bottom" = "Wyrównaj do dołu" "Align center" = "Wyrównaj do środka" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 0f631556..0704bfc5 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -74,6 +74,7 @@ status = "draft — needs native review" "Advanced stroke" = "Traço avançado" "After delay" = "Após atraso" "After delay {ms}ms" = "Após {ms} ms" +"Agent" = "Agente" "Align" = "Alinhamento" "Align bottom" = "Alinhar embaixo" "Align center" = "Alinhar ao centro" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 6e6932d5..d1e84663 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -78,6 +78,7 @@ name = "Русский" "Advanced stroke" = "Расширенные настройки обводки" "After delay" = "После задержки" "After delay {ms}ms" = "После задержки {ms} мс" +"Agent" = "Агент" "Align" = "Выравнивание" "Align bottom" = "Выровнять по нижнему краю" "Align center" = "Выровнять по центру" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index 01b3d7b9..234158c4 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -78,6 +78,7 @@ name = "Svenska" "Advanced stroke" = "Avancerad kontur" "After delay" = "Efter fördröjning" "After delay {ms}ms" = "Efter fördröjning på {ms} ms" +"Agent" = "Agent" "Align" = "Justera" "Align bottom" = "Justera nederkant" "Align center" = "Centrera" diff --git a/crates/ui-egui/locales/th.toml b/crates/ui-egui/locales/th.toml index dc4ea922..6afb9206 100644 --- a/crates/ui-egui/locales/th.toml +++ b/crates/ui-egui/locales/th.toml @@ -80,6 +80,7 @@ status = "draft — needs native review" "Advanced stroke" = "เส้นขอบขั้นสูง" "After delay" = "หลังหน่วงเวลา" "After delay {ms}ms" = "หลังหน่วงเวลา {ms} มิลลิวินาที" +"Agent" = "เอเจนต์" "Align" = "จัดแนว" "Align bottom" = "จัดแนวชิดล่าง" "Align center" = "จัดแนวกึ่งกลาง" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index 58a0f4e3..0f0f8732 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -78,6 +78,7 @@ name = "Türkçe" "Advanced stroke" = "Gelişmiş kontur" "After delay" = "Gecikmeden sonra" "After delay {ms}ms" = "{ms} ms gecikmeden sonra" +"Agent" = "Ajan" "Align" = "Hizala" "Align bottom" = "Alta hizala" "Align center" = "Ortaya hizala" diff --git a/crates/ui-egui/locales/vi.toml b/crates/ui-egui/locales/vi.toml index 19253912..7c9740ac 100644 --- a/crates/ui-egui/locales/vi.toml +++ b/crates/ui-egui/locales/vi.toml @@ -75,6 +75,7 @@ status = "draft — needs native review" "Advanced stroke" = "Nét nâng cao" "After delay" = "Sau khoảng trễ" "After delay {ms}ms" = "Sau khoảng trễ {ms}ms" +"Agent" = "Tác nhân" "Align" = "Căn chỉnh" "Align bottom" = "Căn dưới" "Align center" = "Căn giữa" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index ce5056b9..946f2604 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -77,6 +77,7 @@ status = "draft — needs native review" "Advanced stroke" = "高级描边" "After delay" = "延迟后" "After delay {ms}ms" = "延迟 {ms} 毫秒后" +"Agent" = "智能体" "Align" = "对齐" "Align bottom" = "底部对齐" "Align center" = "居中对齐" diff --git a/crates/ui-egui/src/canvas/comments.rs b/crates/ui-egui/src/canvas/comments.rs index 7e3f84cf..a9e9c126 100644 --- a/crates/ui-egui/src/canvas/comments.rs +++ b/crates/ui-egui/src/canvas/comments.rs @@ -630,6 +630,19 @@ fn hover_button(ui: &mut Ui, id: Id, icon: Icon, show: bool, tip: &str) -> egui: resp.on_hover_text(tip) } +/// A message's author, with a small "Agent" label when an AI agent wrote it. +fn author_name(ui: &mut Ui, t: &Tokens, m: &Message) { + ui.label(egui::RichText::new(&m.author).font(semibold(12.0)).color(t.text)); + if !m.agent { + return; + } + let galley = ui.painter().layout_no_wrap(tr("Agent").to_string(), semibold(10.0), t.text_secondary); + let (r, resp) = ui.allocate_exact_size(galley.size() + vec2(8.0, 2.0), Sense::hover()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, tr("Agent"))); + ui.painter().rect_filled(r, CornerRadius::same(4), t.hover); + ui.painter().galley(r.center() - galley.size() / 2.0, galley, t.text_secondary); +} + /// What every message of a thread needs to know. struct ThreadCtx<'a> { c: &'a Comment, @@ -659,7 +672,7 @@ fn message( let (r, _) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::hover()); avatar(ui.painter(), r.center(), 12.0, &m.author); ui.add_space(4.0); - ui.label(egui::RichText::new(&m.author).font(semibold(12.0)).color(t.text)); + author_name(ui, &t, m); let when = if m.edited > 0 { trf("{when} · edited", &[("when", &ago(m.created))]) } else { ago(m.created) }; ui.label(egui::RichText::new(when).font(regular(11.0)).color(t.text_tertiary)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { diff --git a/crates/ui-egui/src/comments_tests.rs b/crates/ui-egui/src/comments_tests.rs index c89a00be..0ccd32f1 100644 --- a/crates/ui-egui/src/comments_tests.rs +++ b/crates/ui-egui/src/comments_tests.rs @@ -73,6 +73,20 @@ fn control_channel_tool_switch_closes_the_unposted_composer() { assert!(comments(&u)["draft"].is_null()); } +#[test] +fn a_control_call_for_an_agent_signs_its_comment_and_the_ui_keeps_yours() { + let mut u = Ui::new(Session::new()); + let run = |u: &mut Ui, p: Value| crate::control::handle(&mut u.app, &egui::Context::default(), "engine.execute", &p).unwrap(); + let c = run(&mut u, json!({"command": "comment.add", "params": {"x": 10, "y": 10, "text": "Bigger please"}}))["id"].clone(); + run(&mut u, json!({"command": "comment.reply", "agent": "Claude", "params": {"id": c, "text": "Done"}})); + u.app.run("comment.reply", json!({"id": c, "text": "Thanks"})); + let doc = u.app.session.doc().unwrap(); + let t = doc.comment(c.as_u64().unwrap()).unwrap(); + let who: Vec<(&str, bool)> = t.messages().map(|m| (m.author.as_str(), m.agent)).collect(); + assert_eq!(who, [("You", false), ("Claude (agent)", true), ("You", false)]); + assert_eq!(u.app.session.agent, None); +} + #[test] fn mention_helpers() { // The `@query` before the cursor. diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 23b7a63e..4cc017e7 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -1339,7 +1339,7 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul let cmd = p.get("command").and_then(Value::as_str).ok_or("`command` required")?; let params = p.get("params").cloned().unwrap_or(json!({})); let layers_generation = app.session.clipboard.layers_generation; - let r = app.session.execute(cmd, ¶ms).map_err(|e| e.to_string()); + let r = app.session.execute_as(p.get("agent").and_then(Value::as_str), cmd, ¶ms).map_err(|e| e.to_string()); app.clear_comment_draft_after_tool_set(cmd, r.is_ok()); app.invalidate_synthetic_copy(layers_generation); // Programmatic calls never open dialogs. diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index 1ab6c588..024b4ca7 100644 --- a/docs/collaboration-parity.md +++ b/docs/collaboration-parity.md @@ -1,6 +1,6 @@ # Collaboration parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (an AI agent attached over MCP comments under its own name, with an Agent label (#1414); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model, @@ -16,7 +16,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors, selection outlines, avatars, follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 | | Cursor chat, audio, high-fives | yes | **partial, in live sessions**: cursor chat (`/` opens a bubble at your pointer; everyone sees it as you type, it fades a few seconds after the last change, Enter sends and Escape takes it back, 100 characters; `collab.cursorChat` for agents); sent as ephemeral presence the host checks and rate-limits, never stored. No audio, high-fives or emote reactions | 2–5 | | Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen; the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser)); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer: no accounts, teams or projects | 8–17 | -| Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences; a behaviour suite from the documentation (pinning and following layers, threads, own edits and deletes, mentions, reactions, C and Shift+C, stepping, filters, saving, copy and paste); restoring a version keeps them. In a live session threads sync and anyone in it can reply, react and resolve (no accounts: a name is who you are) | (shared once multiplayer lands) | +| Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences (an AI agent attached over MCP comments as its own name, marked Agent); a behaviour suite from the documentation (pinning and following layers, threads, own edits and deletes, mentions, reactions, C and Shift+C, stepping, filters, saving, copy and paste); restoring a version keeps them. In a live session threads sync and anyone in it can reply, react and resolve (no accounts: a name is who you are) | (shared once multiplayer lands) | | Version history: named versions, autosave checkpoints, view before restore, restore, duplicate | yes (cloud) | **done**, saved in the `.figcraft` file: restoring keeps the replaced design as an autosave first, a titled autosave becomes a named version, merging a branch records the file before and after. Versions are per person in a live session (a shared history needs accounts); no comparing two versions (Dev Mode compares a ready-for-dev layer with its state when marked) | — | | Branching and merging | yes (paid plans) | **done, also in live sessions**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived; entries for its creation and updates from main); fills, strokes, effects, layout grids, export settings, guides, interactions, annotations and Dev Mode resources merge item by item. In a live session a branch stays with its author (its ids in their own client space, far above the main file's) and merging it is one edit everyone gets; peers' edits merge against the branch's own base and conflicts resolve as offline; a view link refuses merging. Left: others in the session can't see or open your branches (the server would have to keep them); text runs conflict as a whole; a branch made offline that adds layers merges only outside a session | 1–2 | | Team / organization libraries | yes | **partial**: a file's library is published to `figcraft-server` under the file and enabled in other files by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names ([components-parity](components-parity.md), [server.md](server.md#team-libraries)). No team-wide library list, accounts or analytics over time | 4–8 | @@ -26,6 +26,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Comments from an AI agent (M8, #1414): an agent attached over MCP commented, reacted and read threads as the user (the name from Preferences) unless it passed `author` on every call. Every command an MCP client runs now acts as that client's name from `initialize` (`clientInfo.title`, else `.name`) with " (agent)" after it, or "AI agent" without one, carried to the app as `engine.execute {agent}`; its messages, reactions and read state are its own, and its messages are marked (`agent: true` in the file and in `comment.list`, an Agent label by the name in the thread). The user's name and the comments made in the app are unchanged, and in a live session the name and marker travel with the thread to everyone. Tests in `comment_agent_tests`, the MCP `agent_comments` and attached suites, the UI comments suite and the collab diff round trip. Numbers unchanged | | 2026-10-29 | minor | A refused batch's fixes in linear time (#1063, from a security audit): the server restores each deleted layer once, walking each subtree once and stopping at layers already restored, and looks up and sends each style, variable, comment or document property fix once. The fixes sent back are unchanged. Deleting every layer of a 20,000-deep chain in a refused batch took 27 s on the hub thread in a debug build (each deleted layer's whole subtree walked again, each entry fix compared with all the earlier ones); it now takes 0.1 s (`a_refused_deep_delete_restores_each_node_once_in_linear_time`). | | 2026-10-29 | minor | Image entries are checked as they apply (#1086, from a security audit): a key must be 1–128 ASCII letters, digits, `-` or `_` — the same rule a file load reads `images/` members by (`figcraft_doc::ImageData::is_valid_key`, shared with the format crate, which now also reads `-` and `_` names back, so the REST importer's `rest-image-missing` placeholder survives a save) — so no collaborator can write `images/../../x` into everyone's saved file; bytes carried under a key with the shape of a content hash must be what that key hashes to (lowercase sha256 hex, exactly the form `figcraft_format::image_hash` produces — an uppercase look-alike is refused), a side is at most 65 535 px (JPEG and GIF size fields are 16-bit and no image this app realistically opens reaches beyond it), and the decoded bytes at most 256 MiB (`figcraft_doc::ImageData::MAX_BYTES`, the cap the `.fig`, `.sketch` and REST importers read images with, so an image a document imported still reaches everyone) — with the encoded text capped before decoding so a hostile payload can't force a large allocation. A key that already holds an image can't be given other bytes (producers key images by their bytes, and the REST importer's placeholder keys always hold the same picture). A refused entry refuses its batch like any invalid change, so every replica's image store stays clean. History written before these checks — a server's snapshot or log, a file published to a server, a snapshot a client joins with — applies without the image entries they refuse (judged against the state they apply to, so a logged replacement under an existing key, or removal of an image in use, is left out too), instead of the file not opening or a restart dropping every edit after the image; a restarted server that left image entries out, or whose saved log puts images, catches every resuming client up with its snapshot instead of that log, which it can't check against what each client holds. An image a layer or style still shows can't be removed: removing it and putting other bytes under its key would otherwise swap the picture. Keys that aren't content-hash shaped keep working within the bounds (the svg importer's FNV-128 keys, the REST importer's placeholder under an imageRef), and 0×0 entries — what the format crate stores for sizes it can't probe — stay valid. Tests in `image_tests`. Numbers unchanged | | 2026-10-24 | minor | Branches in live sessions (gap 17): creating, editing, reviewing, updating from main and merging a branch work while its file is in a session. The branch stays with its author and makes its ids in their own client space 2^24 above the main file's, so the server takes the layers it adds; merging is one synced edit and one step of the author's undo history; others' edits on main merge against the branch's own base and conflicts resolve as offline; a view link still refuses merging; a branch made outside the session that adds layers, styles, variables or comments is refused with a reason. A branch's history gets entries for its creation and updates from main. No wire format change. Row wording also brought up to date with #798 (export settings and guides merge item by item). Numbers unchanged | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 6c6f20cf..8b7c225f 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -102,6 +102,11 @@ JSON or the gradient shorthand `{"type":"linearGradient","stops":["#hex","#hex"] (as in `docs/mcp.md`). A parameter the command's doc (`engine.commands`) doesn't list is an error naming it and the ones it takes, never ignored; `ids` and `id` are always taken. +`agent` (a string, next to `command`) runs the command for an AI agent of that name, as the MCP +server does with its client's name: comments it makes are signed " (agent)" ("AI agent" for +an empty name), marked `"agent": true`, and keep their own read state. Without it the command acts +as the user (the name from Preferences). + ```json {"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":10,"y":10,"width":100,"height":50}}} {"id":1,"ok":true,"result":{"id":2}} diff --git a/docs/mcp.md b/docs/mcp.md index c9978dea..12ab9680 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1163,9 +1163,15 @@ pin stays where it was, on the nearest surviving parent. Driven with `run_comman first one, or `reply`: an index into its replies; deleting the first message deletes the thread), `comment.react {id, emoji, reply?, on?}` (toggles your reaction), `comment.resolve {id, resolved?}`, `comment.move {id, x, y, anchor?}` (pins it to the frame under the new point unless `anchor: false`). -Everyone acts as the name from Preferences (`pref.userName {name}`, default "You") unless `author` -says otherwise; only a message's author can edit or delete it. `@Name` in a message mentions -someone (`comment.people` lists the names used in the file). +An agent comments under its own name, never the user's: every command an MCP client runs acts as +that client's name from `initialize` (`clientInfo.title`, else `clientInfo.name`) with " (agent)" +after it, e.g. "Claude (agent)", or "AI agent" when the client gave none. Its messages, reactions +and read state are its own, it can edit and delete only its own messages, and its messages carry +`"agent": true` in `comment.list` (the app shows an **Agent** label by the name); the user's name in +Preferences is unchanged. It is set once per connection, so no command needs `author`; an `author` +param still wins. In the app, comments act as the name from Preferences (`pref.userName {name}`, +default "You"); only a message's author can edit or delete it. `@Name` in a message mentions +someone (`comment.people` lists the names used in the file and, as `you`, who is acting). Read state is kept per name: a new message is unread for everyone but its author; `comment.markRead {id? | link?, read?}` marks one thread (or, without `id`, all of them) read or diff --git a/docs/roadmap.md b/docs/roadmap.md index bae1acdf..a89da72c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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-10-28 · **Change:** minor (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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Comments from an AI agent (M8, #1414): an agent attached over MCP commented, reacted and read threads as the user (the name from Preferences) unless it passed `author` on every call. Every command an MCP client runs now acts as that client's name from `initialize` (`clientInfo.title`, else `.name`) with " (agent)" after it, or "AI agent" without one, carried to the app as `engine.execute {agent}`; its messages, reactions and read state are its own, and its messages are marked (`agent: true` in the file and in `comment.list`, an Agent label by the name in the thread). The user's name and the comments made in the app are unchanged, and in a live session the name and marker travel with the thread to everyone. Tests in `comment_agent_tests`, the MCP `agent_comments` and attached suites, the UI comments suite and the collab diff round trip. Numbers unchanged | | 2026-10-29 | minor | M3, #1338: the instance-swap property's menu (on an instance, its main component's default and the component playground) and the instance's swap menu open with a search field at the top that has the keyboard; typing filters the components by name (any case, headings without matches drop out), an empty result reads "No matching components", Up and Down step through the matches and Enter picks one (the first without a step), Escape clears the search and then closes the menu. Only the list scrolls; the menu grows back when the search is cleared. Tests: `component_swap_search_tests`. Numbers unchanged | | 2026-10-29 | minor | A refused batch's fixes in linear time (#1063, from a security audit): the server restores each deleted layer once, walking each subtree once and stopping at layers already restored, and looks up and sends each style, variable, comment or document property fix once. The fixes sent back are unchanged. Deleting every layer of a 20,000-deep chain in a refused batch took 27 s on the hub thread in a debug build (each deleted layer's whole subtree walked again, each entry fix compared with all the earlier ones); it now takes 0.1 s (`a_refused_deep_delete_restores_each_node_once_in_linear_time`). | | 2026-10-29 | minor | M12, #1387: while a connector is dragged out with the connector tool, the canvas shows the connector itself as it will be made (its straight, elbow or curved route, line, weight and arrowhead, built by the same code as `board.connector`), not a thin line; the object under the pointer is outlined with its four attach points (the middles of its sides), the one the end goes to ringed; within 10 px of one the end keeps that side (`fromSide`/`toSide`, and `side` when re-attaching). Hovering with the tool shows the attach points too. Escape drops the preview; releasing makes the connector in one undo step. Tests: `board_connector_tests`. Numbers unchanged | From 4d47c350c1d29ebecf2e7db05c65302dd1f4e2b1 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:30:36 +0100 Subject: [PATCH 20/63] Neutralise CR and form feed in CSS variable export strings Found while testing crates/engine/src/cmd/variables_io.rs:~92 (css_safe, used by css for String values and mode names) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/variables_io.rs | 2 +- crates/engine/src/variables_io_tests.rs | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/variables_io.rs b/crates/engine/src/cmd/variables_io.rs index b9e73f13..f829785b 100644 --- a/crates/engine/src/cmd/variables_io.rs +++ b/crates/engine/src/cmd/variables_io.rs @@ -62,7 +62,7 @@ fn export(s: &mut Session, p: &Value) -> Result { /// `text` as a CSS comment or quoted string body. fn css_safe(text: &str) -> String { - text.replace("*/", "* /").replace(['"', '\\', '\n'], " ") + text.replace("*/", "* /").replace(['"', '\\', '\n', '\r', '\u{c}'], " ") } /// Each collection's variables as custom properties (named as generated code names them): the diff --git a/crates/engine/src/variables_io_tests.rs b/crates/engine/src/variables_io_tests.rs index e8a0239e..55a2336b 100644 --- a/crates/engine/src/variables_io_tests.rs +++ b/crates/engine/src/variables_io_tests.rs @@ -513,3 +513,15 @@ fn review_a_long_alias_chain_keeps_its_role_to_the_end() { let css = ex(&mut s, "variable.export", json!({"format": "css"}))["text"].as_str().unwrap_or_default().to_string(); assert!(css.contains("--o0: 0.5;"), "{css}"); } + +#[test] +fn css_export_neutralises_every_css_newline_in_strings() { + let mut s = Session::new(); + ex(&mut s, "variable.createCollection", json!({"name": "Brand"})); + ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "t", "type": "string", "value": "a\rb\u{c}c"})); + for format in ["css", "tailwind"] { + let css = ex(&mut s, "variable.export", json!({"format": format}))["text"].as_str().unwrap_or_default().to_string(); + assert!(!css.contains('\r') && !css.contains('\u{c}'), "{css:?}"); + assert!(css.contains("\"a b c\""), "{css:?}"); + } +} From b2421885d06552214489a5d75c7a97d5768fd513 Mon Sep 17 00:00:00 2001 From: BFlat Date: Thu, 29 Oct 2026 19:32:30 +0700 Subject: [PATCH 21/63] M9: sibling placement in live sessions by binary search (#1065) (#1523) Every replica placed a created, moved or reordered layer by counting each sibling that sorts before it, so a batch of many layers under one parent took quadratic time, on the server's single hub thread too. `Replica::slot` now binary-searches the `(key, id)` order on each side of the layer's old place (a position change has already given it its new key). The order is unchanged, ties and fractional positions included. 20,000 creations and 10,000 moves under two parents: 20.05 s before, 0.33 s after (debug build). Fixes #1065 --- ROADMAP.md | 3 +- crates/collab/src/diff_tests.rs | 59 +++++++++++++++++++++++++++++++++ crates/collab/src/replica.rs | 10 ++++-- docs/collaboration-parity.md | 4 ++- docs/roadmap.md | 3 +- 5 files changed, 74 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index fe4e6ea6..4484aa72 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-29 · **Change:** minor (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-10-29 · **Change:** minor (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 @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Sibling placement in live sessions (#1065, from a security audit): every replica (the server's and each app's) placed a created, moved or reordered layer by counting each sibling that sorts before it, so a batch of many layers under one parent took quadratic time, on the server's single hub thread too. It now binary-searches the siblings' `(key, id)` order on each side of the layer's old place. The order is unchanged, ties and fractional positions included: a test replays 5,000 creations and 2,500 moves against the counting rule. 20,000 creations and 10,000 moves under two parents took 20 s in a debug build; they now take 0.3 s. | | 2026-10-29 | minor | Comments from an AI agent (M8, #1414): an agent attached over MCP commented, reacted and read threads as the user (the name from Preferences) unless it passed `author` on every call. Every command an MCP client runs now acts as that client's name from `initialize` (`clientInfo.title`, else `.name`) with " (agent)" after it, or "AI agent" without one, carried to the app as `engine.execute {agent}`; its messages, reactions and read state are its own, and its messages are marked (`agent: true` in the file and in `comment.list`, an Agent label by the name in the thread). The user's name and the comments made in the app are unchanged, and in a live session the name and marker travel with the thread to everyone. Tests in `comment_agent_tests`, the MCP `agent_comments` and attached suites, the UI comments suite and the collab diff round trip. Numbers unchanged | | 2026-10-29 | minor | M3, #1338: the instance-swap property's menu (on an instance, its main component's default and the component playground) and the instance's swap menu open with a search field at the top that has the keyboard; typing filters the components by name (any case, headings without matches drop out), an empty result reads "No matching components", Up and Down step through the matches and Enter picks one (the first without a step), Escape clears the search and then closes the menu. Only the list scrolls; the menu grows back when the search is cleared. Tests: `component_swap_search_tests`. Numbers unchanged | | 2026-10-29 | minor | A refused batch's fixes in linear time (#1063, from a security audit): the server restores each deleted layer once, walking each subtree once and stopping at layers already restored, and looks up and sends each style, variable, comment or document property fix once. The fixes sent back are unchanged. Deleting every layer of a 20,000-deep chain in a refused batch took 27 s on the hub thread in a debug build (each deleted layer's whole subtree walked again, each entry fix compared with all the earlier ones); it now takes 0.1 s (`a_refused_deep_delete_restores_each_node_once_in_linear_time`). | diff --git a/crates/collab/src/diff_tests.rs b/crates/collab/src/diff_tests.rs index ab3d5be3..6cc2604a 100644 --- a/crates/collab/src/diff_tests.rs +++ b/crates/collab/src/diff_tests.rs @@ -251,3 +251,62 @@ fn invalid_changes_are_refused_and_lenient_mode_skips_them() { let mut r = a.clone(); assert!(r.apply(&[Change::Delete { id: NodeId(12345) }, Change::Remove { entry: Entry::Comment(1) }], Mode::Strict).is_ok()); } + +/// Where `id` goes among `kids`, as placement worked before it searched: after every other sibling +/// that sorts before it by `(key, id)`, counted one by one. +fn place_counting(kids: &mut Vec, keys: &BTreeMap, id: NodeId) { + kids.retain(|c| *c != id); + let key = |c: NodeId| keys.get(&c).map_or("", String::as_str); + let at = kids.iter().filter(|c| (key(**c), **c) < (key(id), id)).count(); + kids.insert(at, id); +} + +#[test] +fn many_sibling_placements_keep_the_order_of_counting_them() { + // The reference counts, so it's quadratic: placing 20,000 here took 20 s before the search + // and 0.3 s after (debug build). + const N: u64 = 5_000; + let mut doc = Document::new("Order"); + let page = doc.first_page().unwrap(); + let parents = [page, doc.create(page, NodeKind::Frame, "A", 0.0, 0.0, 10.0, 10.0).unwrap()]; + let mut r = Replica::new(doc); + let mut rng = crate::sim::Rng::new(1065); + // Few whole keys and the fractions between them: many ties (broken by id), many fractions. + let whole = crate::frac::n_between(None, None, 12).unwrap(); + let mut pool = whole.clone(); + pool.extend(whole.windows(2).map(|w| crate::frac::between(Some(&w[0]), Some(&w[1])).unwrap())); + let mut keys: BTreeMap = parents.iter().map(|p| (*p, r.key(*p).to_string())).collect(); + let mut kids: BTreeMap> = parents.iter().map(|p| (*p, r.document().node(*p).unwrap().children.clone())).collect(); + let mut parent_of = BTreeMap::new(); + // One batch of creations in a scrambled id order, under either parent. + let mut creates = Vec::new(); + for i in 0..N { + let (id, p, key) = (NodeId(1_000_000 + (i * 7919) % N), parents[rng.below(2)], pool[rng.below(pool.len())].clone()); + keys.insert(id, key.clone()); + parent_of.insert(id, p); + place_counting(kids.get_mut(&p).unwrap(), &keys, id); + creates.push(Change::Create { id, kind: Json::of(&NodeKind::Rectangle).unwrap(), parent: Some(p), position: key, props: vec![] }); + } + // Then a batch of moves: to another key (often a tie), or under either parent (its own too). + let mut moves = Vec::new(); + for _ in 0..N / 2 { + let id = NodeId(1_000_000 + rng.below(N as usize) as u64); + if rng.chance(0.5) { + let key = pool[rng.below(pool.len())].clone(); + keys.insert(id, key.clone()); + place_counting(kids.get_mut(&parent_of[&id]).unwrap(), &keys, id); + moves.push(Change::Set { id, prop: Prop::Position, value: Json::of(&key).unwrap() }); + } else { + let p = parents[rng.below(2)]; + let old = parent_of.insert(id, p).unwrap(); + kids.get_mut(&old).unwrap().retain(|c| *c != id); + place_counting(kids.get_mut(&p).unwrap(), &keys, id); + moves.push(Change::Set { id, prop: Prop::Parent, value: Json::of(&Some(p)).unwrap() }); + } + } + r.apply(&creates, Mode::Strict).unwrap(); + r.apply(&moves, Mode::Strict).unwrap(); + for p in parents { + assert_eq!(r.document().node(p).unwrap().children, kids[&p], "children of {p}"); + } +} diff --git a/crates/collab/src/replica.rs b/crates/collab/src/replica.rs index 0bf0391a..34508296 100644 --- a/crates/collab/src/replica.rs +++ b/crates/collab/src/replica.rs @@ -175,9 +175,15 @@ impl Replica { } /// Where `id` with `key` goes among `siblings` (sorted by `(key, id)`; `id` itself skipped): - /// how many of them sort before it. + /// how many of them sort before it. A binary search on each side of `id`, which may still + /// sit where its old key put it. fn slot(&self, siblings: &[NodeId], id: NodeId, key: &str) -> usize { - siblings.iter().filter(|c| **c != id && (self.key(**c), **c) < (key, id)).count() + let before = |c: &NodeId| (self.key(*c), *c) < (key, id); + let (head, tail) = match siblings.iter().position(|c| *c == id) { + Some(i) => (siblings.get(..i).unwrap_or_default(), siblings.get(i + 1..).unwrap_or_default()), + None => (siblings, &[][..]), + }; + head.partition_point(before) + tail.partition_point(before) } /// Apply `changes` in order. Node changes come first in a batch made by [`diff`](crate::diff); diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index 024b4ca7..eef76b6c 100644 --- a/docs/collaboration-parity.md +++ b/docs/collaboration-parity.md @@ -1,6 +1,6 @@ # Collaboration parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (an AI agent attached over MCP comments under its own name, with an Agent label (#1414); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (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); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model, @@ -12,6 +12,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Feature | Figma | FigCraft | Hours | |---|---|---|---| +| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). Placing a layer among its siblings is a binary search, so large pastes and reorders in one batch no longer take quadratic time (#1065). Left: server archives beyond Linux | 25–55 | | Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). A refused batch's fixes take time linear in the batch: each deleted layer is restored once and each entry fix looked up once (#1063). Left: server archives beyond Linux | 25–55 | | Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors, selection outlines, avatars, follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 | | Cursor chat, audio, high-fives | yes | **partial, in live sessions**: cursor chat (`/` opens a bubble at your pointer; everyone sees it as you type, it fades a few seconds after the last change, Enter sends and Escape takes it back, 100 characters; `collab.cursorChat` for agents); sent as ephemeral presence the host checks and rate-limits, never stored. No audio, high-fives or emote reactions | 2–5 | @@ -26,6 +27,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Sibling placement in live sessions (#1065, from a security audit): every replica (the server's and each app's) placed a created, moved or reordered layer by counting each sibling that sorts before it, so a batch of many layers under one parent took quadratic time, on the server's single hub thread too. It now binary-searches the siblings' `(key, id)` order on each side of the layer's old place. The order is unchanged, ties and fractional positions included: a test replays 5,000 creations and 2,500 moves against the counting rule. 20,000 creations and 10,000 moves under two parents took 20 s in a debug build; they now take 0.3 s. | | 2026-10-29 | minor | Comments from an AI agent (M8, #1414): an agent attached over MCP commented, reacted and read threads as the user (the name from Preferences) unless it passed `author` on every call. Every command an MCP client runs now acts as that client's name from `initialize` (`clientInfo.title`, else `.name`) with " (agent)" after it, or "AI agent" without one, carried to the app as `engine.execute {agent}`; its messages, reactions and read state are its own, and its messages are marked (`agent: true` in the file and in `comment.list`, an Agent label by the name in the thread). The user's name and the comments made in the app are unchanged, and in a live session the name and marker travel with the thread to everyone. Tests in `comment_agent_tests`, the MCP `agent_comments` and attached suites, the UI comments suite and the collab diff round trip. Numbers unchanged | | 2026-10-29 | minor | A refused batch's fixes in linear time (#1063, from a security audit): the server restores each deleted layer once, walking each subtree once and stopping at layers already restored, and looks up and sends each style, variable, comment or document property fix once. The fixes sent back are unchanged. Deleting every layer of a 20,000-deep chain in a refused batch took 27 s on the hub thread in a debug build (each deleted layer's whole subtree walked again, each entry fix compared with all the earlier ones); it now takes 0.1 s (`a_refused_deep_delete_restores_each_node_once_in_linear_time`). | | 2026-10-29 | minor | Image entries are checked as they apply (#1086, from a security audit): a key must be 1–128 ASCII letters, digits, `-` or `_` — the same rule a file load reads `images/` members by (`figcraft_doc::ImageData::is_valid_key`, shared with the format crate, which now also reads `-` and `_` names back, so the REST importer's `rest-image-missing` placeholder survives a save) — so no collaborator can write `images/../../x` into everyone's saved file; bytes carried under a key with the shape of a content hash must be what that key hashes to (lowercase sha256 hex, exactly the form `figcraft_format::image_hash` produces — an uppercase look-alike is refused), a side is at most 65 535 px (JPEG and GIF size fields are 16-bit and no image this app realistically opens reaches beyond it), and the decoded bytes at most 256 MiB (`figcraft_doc::ImageData::MAX_BYTES`, the cap the `.fig`, `.sketch` and REST importers read images with, so an image a document imported still reaches everyone) — with the encoded text capped before decoding so a hostile payload can't force a large allocation. A key that already holds an image can't be given other bytes (producers key images by their bytes, and the REST importer's placeholder keys always hold the same picture). A refused entry refuses its batch like any invalid change, so every replica's image store stays clean. History written before these checks — a server's snapshot or log, a file published to a server, a snapshot a client joins with — applies without the image entries they refuse (judged against the state they apply to, so a logged replacement under an existing key, or removal of an image in use, is left out too), instead of the file not opening or a restart dropping every edit after the image; a restarted server that left image entries out, or whose saved log puts images, catches every resuming client up with its snapshot instead of that log, which it can't check against what each client holds. An image a layer or style still shows can't be removed: removing it and putting other bytes under its key would otherwise swap the picture. Keys that aren't content-hash shaped keep working within the bounds (the svg importer's FNV-128 keys, the REST importer's placeholder under an imageRef), and 0×0 entries — what the format crate stores for sizes it can't probe — stay valid. Tests in `image_tests`. Numbers unchanged | diff --git a/docs/roadmap.md b/docs/roadmap.md index a89da72c..c9f661e9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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-10-28 · **Change:** minor (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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Sibling placement in live sessions (#1065, from a security audit): every replica (the server's and each app's) placed a created, moved or reordered layer by counting each sibling that sorts before it, so a batch of many layers under one parent took quadratic time, on the server's single hub thread too. It now binary-searches the siblings' `(key, id)` order on each side of the layer's old place. The order is unchanged, ties and fractional positions included: a test replays 5,000 creations and 2,500 moves against the counting rule. 20,000 creations and 10,000 moves under two parents took 20 s in a debug build; they now take 0.3 s. | | 2026-10-29 | minor | Comments from an AI agent (M8, #1414): an agent attached over MCP commented, reacted and read threads as the user (the name from Preferences) unless it passed `author` on every call. Every command an MCP client runs now acts as that client's name from `initialize` (`clientInfo.title`, else `.name`) with " (agent)" after it, or "AI agent" without one, carried to the app as `engine.execute {agent}`; its messages, reactions and read state are its own, and its messages are marked (`agent: true` in the file and in `comment.list`, an Agent label by the name in the thread). The user's name and the comments made in the app are unchanged, and in a live session the name and marker travel with the thread to everyone. Tests in `comment_agent_tests`, the MCP `agent_comments` and attached suites, the UI comments suite and the collab diff round trip. Numbers unchanged | | 2026-10-29 | minor | M3, #1338: the instance-swap property's menu (on an instance, its main component's default and the component playground) and the instance's swap menu open with a search field at the top that has the keyboard; typing filters the components by name (any case, headings without matches drop out), an empty result reads "No matching components", Up and Down step through the matches and Enter picks one (the first without a step), Escape clears the search and then closes the menu. Only the list scrolls; the menu grows back when the search is cleared. Tests: `component_swap_search_tests`. Numbers unchanged | | 2026-10-29 | minor | A refused batch's fixes in linear time (#1063, from a security audit): the server restores each deleted layer once, walking each subtree once and stopping at layers already restored, and looks up and sends each style, variable, comment or document property fix once. The fixes sent back are unchanged. Deleting every layer of a 20,000-deep chain in a refused batch took 27 s on the hub thread in a debug build (each deleted layer's whole subtree walked again, each entry fix compared with all the earlier ones); it now takes 0.1 s (`a_refused_deep_delete_restores_each_node_once_in_linear_time`). | From 714bbfcdaa186b393c71b41676ddcf7d060d281a Mon Sep 17 00:00:00 2001 From: BFlat Date: Thu, 29 Oct 2026 19:47:41 +0700 Subject: [PATCH 22/63] M8: MCP and control calls name the document they act on (#998) (#1533) Tools that act on a document, and the control methods engine.execute, document.inspect, node.get and ui.render, take an optional `document` (an open file's id from doc.list / document.inspect, or its path). The call acts on that file and the tab in front stays in front, so an attached agent keeps editing its own file when the user switches tabs. --- ROADMAP.md | 3 +- crates/engine/src/automation/inspect.rs | 1 + crates/engine/src/cmd/file.rs | 14 +++-- crates/engine/src/lib.rs | 1 + crates/engine/src/session.rs | 1 + crates/engine/src/session/access.rs | 39 ++++++++++++ crates/mcp/src/headless.rs | 16 ++++- crates/mcp/src/tools.rs | 64 +++++++++++++++++++- crates/mcp/tests/it/attached.rs | 20 ++++++ crates/mcp/tests/it/protocol.rs | 2 +- crates/ui-egui/src/control.rs | 55 +++++++++++++++-- crates/ui-egui/src/control/document_tests.rs | 62 +++++++++++++++++++ docs/control-protocol.md | 7 +++ docs/mcp.md | 16 ++++- docs/roadmap.md | 3 +- docs/target-app-parity.md | 3 +- 16 files changed, 289 insertions(+), 18 deletions(-) create mode 100644 crates/ui-egui/src/control/document_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 4484aa72..b962fb6d 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-29 · **Change:** minor (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-10-29 · **Change:** minor (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 @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M8, #998: MCP tools that act on a document (`run_command`, `batch`, `create_node`, `set_properties`, `inspect_document`, `get_node`, `select`, `export`, `screenshot`, `save_file`, `undo`, `redo`) take an optional `document`, an open file's `id` (`doc.list` and the `document.inspect` outline now carry it) or path, and so do the control methods `engine.execute`, `document.inspect`, `node.get` and `ui.render`: the call acts on that file and the tab in front stays in front, so an attached agent keeps editing its own file when the user switches tabs. Calls without it act on the file in front as before; other control methods refuse a `document` behind the tab in front. Numbers unchanged | | 2026-10-29 | minor | Sibling placement in live sessions (#1065, from a security audit): every replica (the server's and each app's) placed a created, moved or reordered layer by counting each sibling that sorts before it, so a batch of many layers under one parent took quadratic time, on the server's single hub thread too. It now binary-searches the siblings' `(key, id)` order on each side of the layer's old place. The order is unchanged, ties and fractional positions included: a test replays 5,000 creations and 2,500 moves against the counting rule. 20,000 creations and 10,000 moves under two parents took 20 s in a debug build; they now take 0.3 s. | | 2026-10-29 | minor | Comments from an AI agent (M8, #1414): an agent attached over MCP commented, reacted and read threads as the user (the name from Preferences) unless it passed `author` on every call. Every command an MCP client runs now acts as that client's name from `initialize` (`clientInfo.title`, else `.name`) with " (agent)" after it, or "AI agent" without one, carried to the app as `engine.execute {agent}`; its messages, reactions and read state are its own, and its messages are marked (`agent: true` in the file and in `comment.list`, an Agent label by the name in the thread). The user's name and the comments made in the app are unchanged, and in a live session the name and marker travel with the thread to everyone. Tests in `comment_agent_tests`, the MCP `agent_comments` and attached suites, the UI comments suite and the collab diff round trip. Numbers unchanged | | 2026-10-29 | minor | M3, #1338: the instance-swap property's menu (on an instance, its main component's default and the component playground) and the instance's swap menu open with a search field at the top that has the keyboard; typing filters the components by name (any case, headings without matches drop out), an empty result reads "No matching components", Up and Down step through the matches and Enter picks one (the first without a step), Escape clears the search and then closes the menu. Only the list scrolls; the menu grows back when the search is cleared. Tests: `component_swap_search_tests`. Numbers unchanged | diff --git a/crates/engine/src/automation/inspect.rs b/crates/engine/src/automation/inspect.rs index 886d2d85..6fd40004 100644 --- a/crates/engine/src/automation/inspect.rs +++ b/crates/engine/src/automation/inspect.rs @@ -344,6 +344,7 @@ pub fn document(s: &Session, depth: usize, all_pages: bool) -> Value { let pages: Vec = doc.pages.iter().map(|p| if all_pages || *p == ds.page { node_json(doc, *p, depth) } else { node_json(doc, *p, 0) }).collect(); json!({ + "id": ds.uid, "name": doc.name, "kind": doc.kind.id(), "path": ds.path, diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index bb1c7199..dd786a09 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -318,15 +318,21 @@ pub fn specs() -> Vec { }), spec!("doc.nextTab", "Next tab", "{}", false, |s, _| cycle_doc(s, 1)), spec!("doc.previousTab", "Previous tab", "{}", false, |s, _| cycle_doc(s, -1)), - spec!("doc.list", "List documents", "{}", false, |s, _| { - Ok(Value::Array( + spec!( + "doc.list", + "List documents", + "{} → [{index, id, name, path, dirty, active}]: `id` names the file in a control or MCP call's `document`", + false, + |s, _| { + Ok(Value::Array( s.docs .iter() .enumerate() - .map(|(i, d)| json!({"index": i, "name": d.doc.name, "path": d.path, "dirty": d.is_dirty(), "active": i == s.active})) + .map(|(i, d)| json!({"index": i, "id": d.uid, "name": d.doc.name, "path": d.path, "dirty": d.is_dirty(), "active": i == s.active})) .collect(), )) - }), + } + ), spec!("doc.home", "Home", "{}", false, |s, _| { s.request_ui("home", json!({})); ok() diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0b801589..e7bddd7d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -108,6 +108,7 @@ pub use derived::{changed_nodes, normalize}; pub use doc_state::{DocState, History, HistoryEntry, Interaction, PREVIEW_READ_ONLY, Preedit, TextEdit, VersionPreview}; pub use error::{EngineError, Result, bad}; pub use prefs::{Clipboard, MAX_RECENT_ACTIONS, Prefs, UI_SCALE_MAX, UI_SCALE_MIN, UiRequest, clamp_ui_scale}; +pub use session::Forward; pub use view_state::ViewState; /// The editing session: open documents, the current tool, clipboard and prefs. diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 389f7f29..99101dc2 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -8,6 +8,7 @@ use super::{ }; mod access; +pub use access::Forward; mod exec; mod new; mod undo; diff --git a/crates/engine/src/session/access.rs b/crates/engine/src/session/access.rs index 05bc45d5..ed83f83b 100644 --- a/crates/engine/src/session/access.rs +++ b/crates/engine/src/session/access.rs @@ -1,6 +1,45 @@ use super::{DocState, Document, EngineError, NodeId, Result, Session, Value, json}; +/// The file [`Session::bring_forward`] put in front for one request, and the tab to bring back. +#[must_use = "pass it to `Session::put_back`"] +pub struct Forward { + target: u64, + shown: Option, +} + impl Session { + /// The uid of open file `target`: its `id` (from `doc.list` or `document.inspect`) or its path. + pub fn document_uid(&self, target: &Value) -> Result { + let found = match target { + Value::Number(n) => n.as_u64().and_then(|id| self.docs.iter().find(|d| d.uid == id)), + Value::String(p) => self.docs.iter().find(|d| d.path.as_deref() == Some(p.as_str())), + _ => None, + }; + found + .map(|d| d.uid) + .ok_or_else(|| EngineError::Other(format!("no open document {target} (`document` takes an `id` from doc.list, or an open file's path)"))) + } + + /// Put open file `target` in front for one request (a control call naming its `document`), + /// without the user's tab changing on screen: run the request, then [`Session::put_back`]. + pub fn bring_forward(&mut self, target: &Value) -> Result { + let uid = self.document_uid(target)?; + let shown = self.ds().ok().map(|d| d.uid); + if let Some(i) = self.docs.iter().position(|d| d.uid == uid) { + self.active = i; + } + Ok(Forward { target: uid, shown }) + } + + /// Bring back the tab that was in front before [`Session::bring_forward`], unless the request + /// itself moved the front to another open file (`file.open`, `doc.switch`…), as it would have. + pub fn put_back(&mut self, f: Forward) { + let moved = self.docs.iter().any(|d| d.uid == f.target) && self.ds().ok().map(|d| d.uid) != Some(f.target); + if let Some(i) = f.shown.and_then(|u| self.docs.iter().position(|d| d.uid == u)).filter(|_| !moved) { + self.active = i; + } + } + pub fn ds(&self) -> Result<&DocState> { self.docs.get(self.active).ok_or(EngineError::NoDocument) } diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index c0f948e3..4f5a2ed1 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -239,8 +239,8 @@ impl Headless { } } -impl Backend for Headless { - fn call(&mut self, method: &str, p: Value) -> Result { +impl Headless { + fn dispatch(&mut self, method: &str, p: Value) -> Result { match method { "engine.execute" => { let cmd = p.get("command").and_then(Value::as_str).ok_or("`command` required")?.to_string(); @@ -301,6 +301,18 @@ impl Backend for Headless { other => Err(format!("unknown method `{other}`")), } } +} + +impl Backend for Headless { + /// With `document` (an `id` from `doc.list`, or an open file's path) the call acts on that + /// file, and the file in front stays in front. + fn call(&mut self, method: &str, p: Value) -> Result { + let Some(target) = p.get("document").filter(|d| !d.is_null()) else { return self.dispatch(method, p) }; + let forward = self.session.bring_forward(target).map_err(|e| e.to_string())?; + let r = self.dispatch(method, p); + self.session.put_back(forward); + r + } fn has_ui(&self) -> bool { false diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 9166141d..6adbbd11 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -105,8 +105,39 @@ pub const TOOL_NAMES: &[&str] = &[ "wait_for_changes", ]; +/// The tools that take `document`: they act on the file it names instead of the one in front. +const DOCUMENT_TOOLS: &[&str] = &[ + "run_command", + "batch", + "create_node", + "set_properties", + "inspect_document", + "get_node", + "select", + "export", + "screenshot", + "save_file", + "undo", + "redo", +]; + /// All tools, in `tools/list` order. pub fn tool_definitions() -> Vec { + let mut tools = tools(); + let document = id_schema( + "The open file to act on: its `id` (from run_command doc.list, or the `document` open_file and inspect_document return) or its path. Default: the file in front. The tab in front stays in front; with an attached app, a screenshot of the window needs the file in front", + ); + for t in &mut tools { + if t.get("name").and_then(Value::as_str).is_some_and(|n| DOCUMENT_TOOLS.contains(&n)) + && let Some(props) = t.pointer_mut("/inputSchema/properties").and_then(Value::as_object_mut) + { + props.insert("document".into(), document.clone()); + } + } + tools +} + +fn tools() -> Vec { let empty = || obj(json!({}), &[]); vec![ tool( @@ -346,6 +377,29 @@ pub fn tool_definitions() -> Vec { // ---------- helpers ---------- +/// A backend whose every call names `document` (a tool's `document` argument). +struct InDocument<'a> { + inner: &'a mut dyn Backend, + document: Value, +} + +impl Backend for InDocument<'_> { + fn call(&mut self, method: &str, mut params: Value) -> Result { + if let Some(o) = params.as_object_mut() { + o.insert("document".into(), self.document.clone()); + } + self.inner.call(method, params) + } + + fn has_ui(&self) -> bool { + self.inner.has_ui() + } + + fn describe(&self) -> String { + self.inner.describe() + } +} + fn exec(b: &mut dyn Backend, cmd: &str, params: Value) -> Result { b.call("engine.execute", json!({"command": cmd, "params": params})) } @@ -468,7 +522,7 @@ fn refuse_unknown_args(schema: &Value, args: &Value, path: &str) -> Result<(), S /// Run one tool. Every failure becomes an `isError` result (never a panic). pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult { - let a = match args_obj(args) { + let mut a = match args_obj(args) { Ok(a) => a, Err(e) => return ToolResult::error(e), }; @@ -479,6 +533,14 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult { { return ToolResult::error(e); } + let mut targeted; + let b: &mut dyn Backend = match a.remove("document").filter(|d| !d.is_null()) { + Some(document) => { + targeted = InDocument { inner: b, document }; + &mut targeted + } + None => b, + }; let r = match name { "list_commands" => list_commands(b, &a), "run_command" => run_command(b, &a), diff --git a/crates/mcp/tests/it/attached.rs b/crates/mcp/tests/it/attached.rs index 2b9e7dc5..d9f96eae 100644 --- a/crates/mcp/tests/it/attached.rs +++ b/crates/mcp/tests/it/attached.rs @@ -161,3 +161,23 @@ fn reconnect_failure_reports_request_not_sent() { let error = backend.call("document.inspect", json!({})).unwrap_err(); assert!(error.contains("request was not sent"), "{error}"); } + +#[test] +fn a_tool_call_naming_its_document_edits_that_file_and_not_the_one_in_front() { + let mut s = Server::new(Box::new(Attached::port(fake_app()).unwrap())); + let text = |r: &Value| -> Value { serde_json::from_str(r["content"][0]["text"].as_str().unwrap()).unwrap() }; + let back = text(&call(&mut s, "inspect_document", json!({"depth": 0})))["id"].clone(); + assert!(back.is_u64(), "inspect_document names the file: {back}"); + assert_eq!(call(&mut s, "run_command", json!({"command": "file.new", "params": {"name": "Front"}}))["isError"], false); + let r = call(&mut s, "create_node", json!({"type": "rectangle", "document": back})); + assert_eq!(r["isError"], false, "{r}"); + let list = text(&call(&mut s, "run_command", json!({"command": "doc.list"}))); + assert_eq!((list[0]["id"].clone(), list[0]["active"].clone(), list[1]["active"].clone()), (back.clone(), json!(false), json!(true))); + let front = text(&call(&mut s, "inspect_document", json!({"depth": 1}))); + assert_eq!((front["name"].as_str(), front["pages"][0]["children"].as_array().map_or(0, Vec::len)), (Some("Front"), 0)); + let behind = text(&call(&mut s, "inspect_document", json!({"depth": 1, "document": back}))); + assert_eq!(behind["pages"][0]["children"].as_array().map_or(0, Vec::len), 1, "{behind}"); + let e = call(&mut s, "inspect_document", json!({"document": 999_999})); + assert_eq!(e["isError"], true); + assert_eq!(call(&mut s, "pointer", json!({"x": 0, "y": 0, "document": back}))["isError"], true, "pointer takes no `document`"); +} diff --git a/crates/mcp/tests/it/protocol.rs b/crates/mcp/tests/it/protocol.rs index f4f07637..2794cc0e 100644 --- a/crates/mcp/tests/it/protocol.rs +++ b/crates/mcp/tests/it/protocol.rs @@ -135,7 +135,7 @@ fn tools_call_happy_and_error_paths() { assert!(c.err("open_file", json!({"path": "/definitely/not/here.figcraft"})).contains("here.figcraft")); // Names a tool or a command doesn't take are refused, not ignored (#1413). let e = c.err("run_command", json!({"command": "inspect.code", "args": {"id": id, "language": "html"}})); - assert!(e.contains("unknown argument `args` (it takes command, params)"), "{e}"); + assert!(e.contains("unknown argument `args` (it takes command, document, params)"), "{e}"); let e = c.err("batch", json!({"commands": [{"command": "comment.list", "args": {"filter": "open"}}]})); assert!(e.contains("unknown argument `commands[0].args`"), "{e}"); let e = c.err("run_command", json!({"command": "comment.list", "params": {"filterr": "open"}})); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 4cc017e7..c92787a0 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -27,13 +27,15 @@ //! - `app.quit {}` //! //! Any method takes `window` (an id from `window.list`) to run in that window, with its tabs and -//! panels; without it, requests go to the window last in front. +//! panels; without it, requests go to the window last in front. `engine.execute`, +//! `document.inspect`, `node.get` and `ui.render` also take `document` (an `id` from `doc.list`, +//! or an open file's path) to act on that file without bringing its tab to the front. use std::sync::{Arc, Mutex}; use egui::Context; use figcraft_engine::cmd::comment::{Filter, Sort}; -use figcraft_engine::{PointerEvent, PointerKind}; +use figcraft_engine::{PointerEvent, PointerKind, Session}; use serde_json::{Value, json}; use crate::FigApp; @@ -356,6 +358,12 @@ fn admit(app: &mut FigApp, r: ControlRequest, window: u64, now: f64) { } let path = params.get("path").and_then(Value::as_str).map(str::to_string); let (document, presentation) = app.windows.presentation(&app.session, window); + if let Err(e) = front_only(&app.session, Some(document), "ui.screenshot", ¶ms) { + if !canceled { + let _ = tx.send(ControlReply { id, result: Err(e) }); + } + return; + } app.capture.shots.push(ShotRequest { id, path, @@ -1332,14 +1340,46 @@ fn ui_set_styles(app: &mut FigApp, p: &Value) -> Result<(), String> { Ok(()) } +/// The methods whose `document` may name a file behind the tab in front. +const DOCUMENT_METHODS: &[&str] = &["engine.execute", "document.inspect", "node.get", "ui.render"]; + +/// The request's `document` (an `id` from `doc.list`, or an open file's path), when it names one. +fn document_of(p: &Value) -> Option<&Value> { + p.get("document").filter(|d| !d.is_null()) +} + +/// Run `f` with the request's `document` in front and the user's tab back afterwards, else on the +/// tab in front (the same swap a command run in the background makes). +fn in_document(app: &mut FigApp, p: &Value, f: impl FnOnce(&mut Session) -> Result) -> Result { + let Some(target) = document_of(p) else { return f(&mut app.session) }; + let forward = app.session.bring_forward(target).map_err(|e| e.to_string())?; + let r = f(&mut app.session); + app.session.put_back(forward); + r +} + +/// A method that acts on the tab in front (`front`, a document uid) refuses a `document` naming +/// another file instead of quietly acting on the wrong one. +fn front_only(session: &Session, front: Option, method: &str, p: &Value) -> Result<(), String> { + let Some(target) = document_of(p) else { return Ok(()) }; + if Some(session.document_uid(target).map_err(|e| e.to_string())?) == front { + return Ok(()); + } + Err(format!("`{method}` acts on the tab in front: `document` can name another open file only for {}", DOCUMENT_METHODS.join(", "))) +} + /// Handle one control method (also used by tests and the headless snapshot tool). pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Result { + if !DOCUMENT_METHODS.contains(&method) { + front_only(&app.session, app.session.ds().ok().map(|d| d.uid), method, p)?; + } match method { "engine.execute" => { let cmd = p.get("command").and_then(Value::as_str).ok_or("`command` required")?; let params = p.get("params").cloned().unwrap_or(json!({})); let layers_generation = app.session.clipboard.layers_generation; - let r = app.session.execute_as(p.get("agent").and_then(Value::as_str), cmd, ¶ms).map_err(|e| e.to_string()); + let agent = p.get("agent").and_then(Value::as_str); + let r = in_document(app, p, |s| s.execute_as(agent, cmd, ¶ms).map_err(|e| e.to_string())); app.clear_comment_draft_after_tool_set(cmd, r.is_ok()); app.invalidate_synthetic_copy(layers_generation); // Programmatic calls never open dialogs. @@ -1358,12 +1398,12 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul )), "document.inspect" => { let depth = p.get("depth").and_then(Value::as_u64).unwrap_or(8).min(64) as usize; - Ok(figcraft_engine::inspect::document(&app.session, depth, b(p, "allPages"))) + in_document(app, p, |s| Ok(figcraft_engine::inspect::document(s, depth, b(p, "allPages")))) } "node.get" => { let id = p.get("id").and_then(figcraft_engine::params::id_of).ok_or("`id` required")?; let depth = p.get("depth").and_then(Value::as_u64).unwrap_or(1) as usize; - app.session.doc().map(|d| figcraft_engine::inspect::node_json(d, id, depth)).map_err(|e| e.to_string()) + in_document(app, p, |s| s.doc().map(|d| figcraft_engine::inspect::node_json(d, id, depth)).map_err(|e| e.to_string())) } "window.new" => { let index = p.get("index").and_then(Value::as_u64).map(|i| i as usize); @@ -1411,7 +1451,7 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul if !ids.is_empty() { params["ids"] = json!(ids); } - app.session.execute("file.export", ¶ms).map_err(|e| e.to_string()) + in_document(app, p, |s| s.execute("file.export", ¶ms).map_err(|e| e.to_string())) } "app.quit" => { app.ui.quit = true; @@ -1488,3 +1528,6 @@ mod roots_tests; #[cfg(test)] mod capture_tests; + +#[cfg(test)] +mod document_tests; diff --git a/crates/ui-egui/src/control/document_tests.rs b/crates/ui-egui/src/control/document_tests.rs new file mode 100644 index 00000000..dbba64dc --- /dev/null +++ b/crates/ui-egui/src/control/document_tests.rs @@ -0,0 +1,62 @@ +//! A control request naming its `document` acts on that file, and the tab in front stays in front. + +use figcraft_engine::Session; +use serde_json::{Value, json}; + +use crate::{FigApp, NoServices}; + +/// Two files open: "Back" behind "Front", which is in front. Returns the app and Back's id. +fn two_files() -> (FigApp, u64) { + let mut s = Session::new(); + s.execute("file.new", &json!({"name": "Front"})).unwrap(); + s.docs[0].doc_mut().name = "Back".into(); + let back = s.docs[0].uid; + (FigApp::new(s, Box::new(NoServices)), back) +} + +fn handle(app: &mut FigApp, method: &str, p: Value) -> Result { + super::handle(app, &egui::Context::default(), method, &p) +} + +fn layers(app: &FigApp, i: usize) -> usize { + let ds = &app.session.docs[i]; + ds.doc.node(ds.page).map_or(0, |p| p.children.len()) +} + +#[test] +fn a_call_naming_the_back_file_edits_it_and_leaves_the_front_one_alone() { + let (mut app, back) = two_files(); + let rect = json!({"command": "shape.rectangle", "params": {"x": 0, "y": 0, "width": 20, "height": 10}, "document": back}); + handle(&mut app, "engine.execute", rect).unwrap(); + assert_eq!((layers(&app, 0), layers(&app, 1)), (1, 0), "the rectangle landed in Back only"); + assert_eq!(app.session.active, 1, "Front stays in front"); + let doc = handle(&mut app, "document.inspect", json!({"depth": 1, "document": back})).unwrap(); + assert_eq!((doc["name"].as_str(), doc["id"].as_u64()), (Some("Back"), Some(back))); + let listed = handle(&mut app, "engine.execute", json!({"command": "doc.list"})).unwrap(); + assert_eq!(listed[0]["id"], json!(back)); + assert!(handle(&mut app, "ui.render", json!({"document": back})).is_ok()); + // An open file's path names it too. + app.session.docs[0].path = Some("/work/back.figcraft".into()); + let doc = handle(&mut app, "document.inspect", json!({"depth": 0, "document": "/work/back.figcraft"})).unwrap(); + assert_eq!(doc["id"], json!(back)); + assert_eq!(app.session.active, 1); +} + +#[test] +fn a_file_that_is_not_open_or_a_ui_method_on_the_back_file_is_an_error() { + let (mut app, back) = two_files(); + let e = handle(&mut app, "document.inspect", json!({"document": 999_999})).unwrap_err(); + assert!(e.contains("no open document 999999"), "{e}"); + let e = handle(&mut app, "ui.type", json!({"text": "x", "document": back})).unwrap_err(); + assert!(e.contains("acts on the tab in front"), "{e}"); + let front = app.session.docs[1].uid; + assert!(handle(&mut app, "ui.inspect", json!({"document": front})).is_ok(), "the file in front may be named"); + assert_eq!(app.session.active, 1); +} + +#[test] +fn a_command_that_opens_a_file_still_brings_it_to_the_front() { + let (mut app, back) = two_files(); + handle(&mut app, "engine.execute", json!({"command": "file.new", "document": back})).unwrap(); + assert_eq!(app.session.active, 2, "a new file opens in front"); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 8b7c225f..3fcc5127 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -65,6 +65,13 @@ window, with its tabs, panels and canvas: `engine.execute` acts on the file it s last in front (the one the user clicked last, or the last `window.new` / `window.focus`). An unknown id is an error. +**Documents.** `engine.execute`, `document.inspect`, `node.get` and `ui.render` take an optional +`document`: an open file's `id` (from `doc.list`, or `document.inspect`'s `id`) or its path. The +request acts on that file without bringing its tab to the front; the tab in front comes back +afterwards unless the command opened or switched to another file itself. A file that isn't open is +an error, and so is a `document` naming a file behind the tab in front on any other method +(`ui.screenshot`, `ui.input`, `ui.key`… act on what the window shows). + ## Confining file access ```sh diff --git a/docs/mcp.md b/docs/mcp.md index 12ab9680..8f53f818 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -345,7 +345,7 @@ a later step can use `{"$step": 0, "field": "ids/0"}` to target the first match. | `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`); all-or-nothing for open documents and their document state. Failed inner commands are removed from the journal; preferences, clipboard, tool state and UI requests are not rolled back. A step can use what an earlier step returned: name it with `"as": "card"` and `{"$ref": "card"}` anywhere in a later step's params becomes the `id` in its result (`{"$step": n}` does the same by zero-based position); `"field"` takes another key or a slash path such as `ids/0` (see Step references below). | | `create_node {type, x, y, width, height, parent, index, name, text, path, props}` | Create a frame, rectangle, ellipse, polygon, star, line, arrow, text, vector, section, component or slice; returns `{id, node}`. | | `set_properties {ids, props}` | `node.set` on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, masks (`isMask`, `maskType: alpha\|vector\|luminance`), handoff status (`devStatus: none\|readyForDev\|completed`; marking again clears "Changed"), a section's `sectionContentsHidden`, text…); returns the updated layers. | -| `inspect_document {depth, allPages}` | The document as JSON (layer tree, styles, selection, history). | +| `inspect_document {depth, allPages}` | The document as JSON (its `id`, layer tree, styles, selection, history). The tools that act on a document also take `document` (see Naming the document below). | | `get_node {id, depth}` | One layer as JSON; `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). | @@ -1300,6 +1300,20 @@ opens one (a new file, the file at `path`, or open file `index` moved out of its focus a window before working on its files, and pass its id to `screenshot {window}` to see it. Headless (one session, no windows) and in the browser the two commands do nothing. +### Naming the document + +Tools act on the file in front, so with an attached app a tab the user clicks mid-task would +change where the next edit lands. `run_command`, `batch`, `create_node`, `set_properties`, +`inspect_document`, `get_node`, `select`, `export`, `screenshot`, `save_file`, `undo` and `redo` +take an optional `document`: the file's `id` (in `run_command {command: "doc.list"}`, and the `id` +of the `document` outline `open_file` and `inspect_document` return) or the path of an open file. +The call acts on that file and the tab in front stays in front, unless the command itself opens or +switches to another file (`file.open`, `file.new`, `doc.switch`), as it would without `document`. A +file that isn't open is an error. `screenshot {window}` captures what the window shows, so it +needs the file in front. Over the control channel, `engine.execute`, `document.inspect`, +`node.get` and `ui.render` take the same `document`; other methods refuse one naming a file behind +the tab in front. Headless sessions take it too. + ### Live sessions People (and agents) edit one file together and see each other's cursors, selections and diff --git a/docs/roadmap.md b/docs/roadmap.md index c9f661e9..cda64272 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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-10-28 · **Change:** minor (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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M8, #998: MCP tools that act on a document (`run_command`, `batch`, `create_node`, `set_properties`, `inspect_document`, `get_node`, `select`, `export`, `screenshot`, `save_file`, `undo`, `redo`) take an optional `document`, an open file's `id` (`doc.list` and the `document.inspect` outline now carry it) or path, and so do the control methods `engine.execute`, `document.inspect`, `node.get` and `ui.render`: the call acts on that file and the tab in front stays in front, so an attached agent keeps editing its own file when the user switches tabs. Calls without it act on the file in front as before; other control methods refuse a `document` behind the tab in front. Numbers unchanged | | 2026-10-29 | minor | Sibling placement in live sessions (#1065, from a security audit): every replica (the server's and each app's) placed a created, moved or reordered layer by counting each sibling that sorts before it, so a batch of many layers under one parent took quadratic time, on the server's single hub thread too. It now binary-searches the siblings' `(key, id)` order on each side of the layer's old place. The order is unchanged, ties and fractional positions included: a test replays 5,000 creations and 2,500 moves against the counting rule. 20,000 creations and 10,000 moves under two parents took 20 s in a debug build; they now take 0.3 s. | | 2026-10-29 | minor | Comments from an AI agent (M8, #1414): an agent attached over MCP commented, reacted and read threads as the user (the name from Preferences) unless it passed `author` on every call. Every command an MCP client runs now acts as that client's name from `initialize` (`clientInfo.title`, else `.name`) with " (agent)" after it, or "AI agent" without one, carried to the app as `engine.execute {agent}`; its messages, reactions and read state are its own, and its messages are marked (`agent: true` in the file and in `comment.list`, an Agent label by the name in the thread). The user's name and the comments made in the app are unchanged, and in a live session the name and marker travel with the thread to everyone. Tests in `comment_agent_tests`, the MCP `agent_comments` and attached suites, the UI comments suite and the collab diff round trip. Numbers unchanged | | 2026-10-29 | minor | M3, #1338: the instance-swap property's menu (on an instance, its main component's default and the component playground) and the instance's swap menu open with a search field at the top that has the keyboard; typing filters the components by name (any case, headings without matches drop out), an empty result reads "No matching components", Up and Down step through the matches and Enter picks one (the first without a step), Escape clears the search and then closes the menu. Only the list scrolls; the menu grows back when the search is cleared. Tests: `component_swap_search_tests`. Numbers unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 98167a69..f5b3b550 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (shadows of images with transparent pixels follow their alpha (#1289); Arabic draft catalog; truncated text fills its last line before the ellipsis and keeps truncating when resized; auto-height text with max lines (#1375, #1376, #1377), numbers unchanged; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (MCP and control calls name their document (#998); shadows of images with transparent pixels follow their alpha (#1289); Arabic draft catalog; truncated text fills its last line before the ellipsis and keeps truncating when resized; auto-height text with max lines (#1375, #1376, #1377), numbers unchanged; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -646,6 +646,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M8, #998: MCP tools that act on a document (`run_command`, `batch`, `create_node`, `set_properties`, `inspect_document`, `get_node`, `select`, `export`, `screenshot`, `save_file`, `undo`, `redo`) take an optional `document`, an open file's `id` (`doc.list` and the `document.inspect` outline now carry it) or path, and so do the control methods `engine.execute`, `document.inspect`, `node.get` and `ui.render`: the call acts on that file and the tab in front stays in front, so an attached agent keeps editing its own file when the user switches tabs. Calls without it act on the file in front as before; other control methods refuse a `document` behind the tab in front. Numbers unchanged | | 2026-10-29 | minor | Shadows of images with transparent pixels (M3, #1289): a drop shadow is cast from the picture's alpha and hidden only behind its pixels, not its whole box (a transparent PNG's shadow was cut away inside its bounding box), unless shown behind transparent areas; its inner shadow shades the inside of its alpha, not the box's edge; the same for Fit images, crops smaller than the box and pattern fills. Opaque pictures that fill their box keep their outline (spread still offsets it exactly); each image is scanned for transparency once. Canvas, PNG, SVG (knocked out by the layer's alpha; an inner shadow embeds a render) and PDF (`image_shadow_tests` in render and svg). Numbers unchanged | | 2026-10-28 | minor | Arabic draft: 1,857 UI strings, 620 command labels, 31 menu wordings; count-neutral plurals and web font gate. Native review and panel mirroring remain open; grade and estimates unchanged. Evidence: `crates/ui-egui/locales/ar.toml`, i18n catalog tests and [translation notes](translating.md#arabic-draft) | | 2026-10-24 | patch | Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds, which landed (`view.outlinesHiddenLayers`, `view.outlinesObjectBounds`, `crates/engine/src/outline_options_tests.rs`). Numbers unchanged | From 436cfa1b9627c49ff7f9482bdd81807fd06d055d Mon Sep 17 00:00:00 2001 From: BFlat Date: Thu, 29 Oct 2026 20:02:46 +0700 Subject: [PATCH 23/63] M9: the replay log is bounded in bytes by default (#1069) (#1524) The batches a session keeps for apps that reconnect were bounded only by count on a desktop host and a private figcraft-server (`max_log_bytes` defaulted to usize::MAX). It now defaults to 64 MiB, and figcraft-server takes it from a new `max_log_mb` setting (default 64; open to the public, also at most a quarter of `max_file_mb` as before). An app behind the trimmed log catches up with a snapshot, as it already did past the count limits. The protocol is unchanged. Nine 8 MiB renames with default limits: all 75.5 MB kept before, 58.7 MB after. Fixes #1069 --- ROADMAP.md | 3 +- apps/figcraft-server/src/cli.rs | 4 +-- apps/figcraft-server/src/config.rs | 21 +++++++++----- apps/figcraft-server/src/public_tests.rs | 13 +++++++++ crates/collab/src/lib.rs | 5 ++-- crates/collab/src/reconnect_tests.rs | 37 ++++++++++++++++++++++++ docs/collab-protocol.md | 7 +++-- docs/collaboration-parity.md | 4 ++- docs/roadmap.md | 3 +- docs/server.md | 3 +- 10 files changed, 82 insertions(+), 18 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b962fb6d..7fe8ffb9 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-29 · **Change:** minor (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-10-29 · **Change:** minor (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 @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Bounded replay log (#1069, from a security audit): the batches a session keeps for apps that reconnect were bounded only by count (10,000 batches, 1,000,000 changes) on a desktop host and a private `figcraft-server`, so a long busy session could hold any number of bytes. `Limits::max_log_bytes` now defaults to 64 MiB, and `figcraft-server` takes it from a new `max_log_mb` setting (default 64; open to the public, also at most a quarter of `max_file_mb`, as before). An app further behind than the trimmed log catches up with the whole file, as it already did past the count limits. Nine 8 MiB renames with default limits kept all 75.5 MB before; now 58.7 MB, and a client that missed them gets a snapshot. The protocol is unchanged. | | 2026-10-29 | minor | M8, #998: MCP tools that act on a document (`run_command`, `batch`, `create_node`, `set_properties`, `inspect_document`, `get_node`, `select`, `export`, `screenshot`, `save_file`, `undo`, `redo`) take an optional `document`, an open file's `id` (`doc.list` and the `document.inspect` outline now carry it) or path, and so do the control methods `engine.execute`, `document.inspect`, `node.get` and `ui.render`: the call acts on that file and the tab in front stays in front, so an attached agent keeps editing its own file when the user switches tabs. Calls without it act on the file in front as before; other control methods refuse a `document` behind the tab in front. Numbers unchanged | | 2026-10-29 | minor | Sibling placement in live sessions (#1065, from a security audit): every replica (the server's and each app's) placed a created, moved or reordered layer by counting each sibling that sorts before it, so a batch of many layers under one parent took quadratic time, on the server's single hub thread too. It now binary-searches the siblings' `(key, id)` order on each side of the layer's old place. The order is unchanged, ties and fractional positions included: a test replays 5,000 creations and 2,500 moves against the counting rule. 20,000 creations and 10,000 moves under two parents took 20 s in a debug build; they now take 0.3 s. | | 2026-10-29 | minor | Comments from an AI agent (M8, #1414): an agent attached over MCP commented, reacted and read threads as the user (the name from Preferences) unless it passed `author` on every call. Every command an MCP client runs now acts as that client's name from `initialize` (`clientInfo.title`, else `.name`) with " (agent)" after it, or "AI agent" without one, carried to the app as `engine.execute {agent}`; its messages, reactions and read state are its own, and its messages are marked (`agent: true` in the file and in `comment.list`, an Agent label by the name in the thread). The user's name and the comments made in the app are unchanged, and in a live session the name and marker travel with the thread to everyone. Tests in `comment_agent_tests`, the MCP `agent_comments` and attached suites, the UI comments suite and the collab diff round trip. Numbers unchanged | diff --git a/apps/figcraft-server/src/cli.rs b/apps/figcraft-server/src/cli.rs index 06f248e0..f0bd5929 100644 --- a/apps/figcraft-server/src/cli.rs +++ b/apps/figcraft-server/src/cli.rs @@ -43,8 +43,8 @@ Options (also keys of the --config TOML file): --data DIR the data directory (default ./figcraft-data) --public-url URL how links name the server behind a reverse proxy (wss://host/path/) --config FILE settings: listen, data, public_url, max_files, max_file_mb, max_links, - max_connections, max_people, compact_after, compact_mb, max_plugins, - max_plugin_mb, max_plugins_mb; for a public server: open_publish, + max_connections, max_people, compact_after, compact_mb, max_log_mb, + max_plugins, max_plugin_mb, max_plugins_mb; for a public server: open_publish, operator_contact, terms_url, trusted_proxy, idle_days, max_total_mb, max_files_per_ip, max_mb_per_ip, max_connections_per_ip, connects_per_minute, publishes_per_hour, messages_per_second diff --git a/apps/figcraft-server/src/config.rs b/apps/figcraft-server/src/config.rs index d5f4f761..01ef3cbd 100644 --- a/apps/figcraft-server/src/config.rs +++ b/apps/figcraft-server/src/config.rs @@ -39,6 +39,9 @@ pub struct Config { pub compact_after: usize, /// …or once the log is this large (MiB). pub compact_mb: u64, + /// Most MiB of recent batches a file in use keeps in memory for apps that reconnect (one + /// further behind catches up with the whole file). + pub max_log_mb: u64, /// Most plugin versions the plugin directory keeps (0 turns it off). pub max_plugins: usize, /// Largest plugin bundle, in MiB. @@ -84,6 +87,7 @@ impl Default for Config { max_people: 256, compact_after: 1000, compact_mb: 16, + max_log_mb: 64, max_plugins: 500, max_plugin_mb: 16, max_plugins_mb: 1024, @@ -209,6 +213,7 @@ impl Config { ("max_people", self.max_people as u64), ("compact_after", self.compact_after as u64), ("compact_mb", self.compact_mb), + ("max_log_mb", self.max_log_mb), ("max_plugin_mb", self.max_plugin_mb), ("max_plugins_mb", self.max_plugins_mb), ] { @@ -216,7 +221,7 @@ impl Config { return Err(format!("`{name}` must be at least 1")); } } - if self.file_mb() > 4096 || self.compact_mb > 4096 { + if self.file_mb() > 4096 || self.compact_mb > 4096 || self.max_log_mb > 4096 { return Err("sizes are in MiB and at most 4096".into()); } // A bundle travels in one message (at most 64 MiB) next to its entry. @@ -274,17 +279,19 @@ impl Config { usize::try_from(self.file_mb().max(self.max_plugin_mb).saturating_add(1).saturating_mul(1 << 20)).unwrap_or(usize::MAX) } - /// The collaboration core's part: when open, messages as large as a file at most, and the - /// batches kept for reconnecting clients (in memory, and in the file's snapshot) at most a - /// quarter of a file's bytes (256 KiB at least). + /// The collaboration core's part: the batches kept for reconnecting clients (in memory, and + /// in the file's snapshot) at most `max_log_mb`; when open, messages as large as a file at + /// most, and those batches also at most a quarter of a file's bytes (256 KiB at least). fn core(&self) -> figcraft_collab::Limits { - let base = figcraft_collab::Limits { max_peers: self.max_people, ..figcraft_collab::Limits::default() }; + let bytes = |mb: u64| usize::try_from(mb.saturating_mul(1 << 20)).unwrap_or(usize::MAX); + let base = + figcraft_collab::Limits { max_peers: self.max_people, max_log_bytes: bytes(self.max_log_mb), ..figcraft_collab::Limits::default() }; if !self.open_publish { return figcraft_collab::Limits { max_message_bytes: base.max_message_bytes.max(self.open_message()), ..base }; } let max = self.open_message(); - let file = usize::try_from(self.file_mb().saturating_mul(1 << 20)).unwrap_or(usize::MAX); - figcraft_collab::Limits { max_message_bytes: max, max_log_bytes: (file / 4).max(256 << 10), ..base } + let log = (bytes(self.file_mb()) / 4).max(256 << 10).min(base.max_log_bytes); + figcraft_collab::Limits { max_message_bytes: max, max_log_bytes: log, ..base } } /// The transport's part. A private server keeps the defaults; open to the public, connections diff --git a/apps/figcraft-server/src/public_tests.rs b/apps/figcraft-server/src/public_tests.rs index dc86d123..37cbabe1 100644 --- a/apps/figcraft-server/src/public_tests.rs +++ b/apps/figcraft-server/src/public_tests.rs @@ -584,6 +584,19 @@ fn files_above_64_mib_fit_every_limit() { assert_eq!(NetLimits::default().max_server_message, 1 << 30); } +#[test] +fn the_batches_kept_for_reconnecting_apps_follow_max_log_mb() { + let log = |cfg: Config| cfg.store().core.max_log_bytes; + assert_eq!(log(Config::default()), 64 << 20, "a private server's log is bounded too"); + assert_eq!(log(Config { max_log_mb: 8, ..Config::default() }), 8 << 20); + // Open to the public: also at most a quarter of a file (16 MiB unless set). + assert_eq!(log(Config { open_publish: true, ..Config::default() }), 4 << 20); + assert_eq!(log(Config { open_publish: true, max_log_mb: 1, ..Config::default() }), 1 << 20); + for mb in [0, 4097] { + assert!(Config { max_log_mb: mb, ..Config::default() }.check().is_err(), "max_log_mb = {mb}"); + } +} + #[test] fn large_files_warn_the_operator() { let public_url = Some("wss://figcraft.example.com/".into()); diff --git a/crates/collab/src/lib.rs b/crates/collab/src/lib.rs index f256ef15..1f8c0f1d 100644 --- a/crates/collab/src/lib.rs +++ b/crates/collab/src/lib.rs @@ -88,7 +88,8 @@ pub struct Limits { pub max_log_batches: usize, /// Most changes in those batches together. pub max_log_changes: usize, - /// About how many bytes those batches may hold together ([`server::weight`]). + /// About how many bytes those batches may hold together ([`server::weight`]). 64 MiB by + /// default, so a long busy session (on a desktop host too) keeps a bounded log. pub max_log_bytes: usize, } @@ -102,7 +103,7 @@ impl Default for Limits { max_name: 64, max_log_batches: 10_000, max_log_changes: 1_000_000, - max_log_bytes: usize::MAX, + max_log_bytes: 64 << 20, } } } diff --git a/crates/collab/src/reconnect_tests.rs b/crates/collab/src/reconnect_tests.rs index 5c7c4205..a5998fee 100644 --- a/crates/collab/src/reconnect_tests.rs +++ b/crates/collab/src/reconnect_tests.rs @@ -133,6 +133,43 @@ fn a_long_absence_catches_up_with_a_snapshot() { assert_eq!(a.view(), s.replica()); } +#[test] +fn the_default_log_is_bounded_in_bytes_and_a_client_behind_it_catches_up_with_a_snapshot() { + // The defaults are what a desktop host runs with. + let limits = Limits::default(); + assert_eq!(limits.max_log_bytes, 64 << 20); + let mut s = Server::new(base(), "tok", limits); + let mut a = join(&mut s); + let mut b = join(&mut s); + let page = s.document().first_page().unwrap(); + a.disconnect(); + s.leave(a.id()); + // Nine renames of 8 MiB each: more than the log holds, in a few batches. + for i in 0..9 { + let m = edit(&mut b, |d| d.node_mut(page).unwrap().name = format!("{i}{}", "y".repeat(8 << 20))).unwrap(); + let out = s.receive(b.id(), m); + deliver(&mut b, &out); + assert!(s.log_bytes() <= limits.max_log_bytes, "{} bytes kept", s.log_bytes()); + } + assert!(s.log_bytes() > 0, "recent batches are still kept"); + let ClientMsg::Hello(h) = a.reconnect(hello()) else { panic!() }; + let (_, out) = s.join(h).unwrap(); + assert!(matches!(&out[0], Outgoing::To(_, ServerMsg::Welcome { catch_up: CatchUp::Snapshot(_), .. }))); + deliver(&mut a, &out); + assert_eq!(a.view(), s.replica()); + // Within the log, a short absence still gets the missed batch only. + a.disconnect(); + s.leave(a.id()); + let m = edit(&mut b, |d| d.node_mut(page).unwrap().name = "Later".into()).unwrap(); + let out = s.receive(b.id(), m); + deliver(&mut b, &out); + let ClientMsg::Hello(h) = a.reconnect(hello()) else { panic!() }; + let (_, out) = s.join(h).unwrap(); + assert!(matches!(&out[0], Outgoing::To(_, ServerMsg::Welcome { catch_up: CatchUp::Delta(d), .. }) if d.len() == 1)); + deliver(&mut a, &out); + assert_eq!(a.view(), s.replica()); +} + #[test] fn after_a_snapshot_undo_leaves_what_others_changed_meanwhile() { let mut s = Server::new(base(), "tok", Limits { max_log_batches: 2, ..Limits::default() }); diff --git a/docs/collab-protocol.md b/docs/collab-protocol.md index f350013c..868806b4 100644 --- a/docs/collab-protocol.md +++ b/docs/collab-protocol.md @@ -861,9 +861,10 @@ Decisions, as built: welcomed as new after all (the server restarted without its state) starts over from the snapshot and is told how many unsent changes couldn't be kept. - **Catch-up.** The server keeps a log of accepted batches (`Limits::max_log_batches`, default - 10 000, and `max_log_changes`, default 1 000 000). If every batch after the client's sequence - number is still in it, the welcome carries them (`CatchUp::Delta`); otherwise the whole document - (`CatchUp::Snapshot`). + 10 000, `max_log_changes`, default 1 000 000, and `max_log_bytes`, default 64 MiB, which a + desktop host uses as they are and `figcraft-server` takes from its `max_log_mb`). If every batch + after the client's sequence number is still in it, the welcome carries them (`CatchUp::Delta`); + otherwise the whole document (`CatchUp::Snapshot`). - **What happened to the batches in flight.** The server remembers per client the last batch id it processed and the latest refused ids (at most 1 024). Batches up to that id that the catch-up doesn't confirm were refused (the user is told); the others are sent again, in order, keeping diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index eef76b6c..68426822 100644 --- a/docs/collaboration-parity.md +++ b/docs/collaboration-parity.md @@ -1,6 +1,6 @@ # Collaboration parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (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); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); 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); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model, @@ -12,6 +12,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Feature | Figma | FigCraft | Hours | |---|---|---|---| +| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). The batches kept for apps that reconnect are bounded in bytes by default (64 MiB; `max_log_mb` on `figcraft-server`), and an app further behind catches up with a snapshot (#1069). Left: server archives beyond Linux | 25–55 | | Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). Placing a layer among its siblings is a binary search, so large pastes and reorders in one batch no longer take quadratic time (#1065). Left: server archives beyond Linux | 25–55 | | Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). A refused batch's fixes take time linear in the batch: each deleted layer is restored once and each entry fix looked up once (#1063). Left: server archives beyond Linux | 25–55 | | Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors, selection outlines, avatars, follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 | @@ -27,6 +28,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Bounded replay log (#1069, from a security audit): the batches a session keeps for apps that reconnect were bounded only by count (10,000 batches, 1,000,000 changes) on a desktop host and a private `figcraft-server`, so a long busy session could hold any number of bytes. `Limits::max_log_bytes` now defaults to 64 MiB, and `figcraft-server` takes it from a new `max_log_mb` setting (default 64; open to the public, also at most a quarter of `max_file_mb`, as before). An app further behind than the trimmed log catches up with the whole file, as it already did past the count limits. Nine 8 MiB renames with default limits kept all 75.5 MB before; now 58.7 MB, and a client that missed them gets a snapshot. The protocol is unchanged. | | 2026-10-29 | minor | Sibling placement in live sessions (#1065, from a security audit): every replica (the server's and each app's) placed a created, moved or reordered layer by counting each sibling that sorts before it, so a batch of many layers under one parent took quadratic time, on the server's single hub thread too. It now binary-searches the siblings' `(key, id)` order on each side of the layer's old place. The order is unchanged, ties and fractional positions included: a test replays 5,000 creations and 2,500 moves against the counting rule. 20,000 creations and 10,000 moves under two parents took 20 s in a debug build; they now take 0.3 s. | | 2026-10-29 | minor | Comments from an AI agent (M8, #1414): an agent attached over MCP commented, reacted and read threads as the user (the name from Preferences) unless it passed `author` on every call. Every command an MCP client runs now acts as that client's name from `initialize` (`clientInfo.title`, else `.name`) with " (agent)" after it, or "AI agent" without one, carried to the app as `engine.execute {agent}`; its messages, reactions and read state are its own, and its messages are marked (`agent: true` in the file and in `comment.list`, an Agent label by the name in the thread). The user's name and the comments made in the app are unchanged, and in a live session the name and marker travel with the thread to everyone. Tests in `comment_agent_tests`, the MCP `agent_comments` and attached suites, the UI comments suite and the collab diff round trip. Numbers unchanged | | 2026-10-29 | minor | A refused batch's fixes in linear time (#1063, from a security audit): the server restores each deleted layer once, walking each subtree once and stopping at layers already restored, and looks up and sends each style, variable, comment or document property fix once. The fixes sent back are unchanged. Deleting every layer of a 20,000-deep chain in a refused batch took 27 s on the hub thread in a debug build (each deleted layer's whole subtree walked again, each entry fix compared with all the earlier ones); it now takes 0.1 s (`a_refused_deep_delete_restores_each_node_once_in_linear_time`). | diff --git a/docs/roadmap.md b/docs/roadmap.md index cda64272..62e6780e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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-10-28 · **Change:** minor (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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Bounded replay log (#1069, from a security audit): the batches a session keeps for apps that reconnect were bounded only by count (10,000 batches, 1,000,000 changes) on a desktop host and a private `figcraft-server`, so a long busy session could hold any number of bytes. `Limits::max_log_bytes` now defaults to 64 MiB, and `figcraft-server` takes it from a new `max_log_mb` setting (default 64; open to the public, also at most a quarter of `max_file_mb`, as before). An app further behind than the trimmed log catches up with the whole file, as it already did past the count limits. Nine 8 MiB renames with default limits kept all 75.5 MB before; now 58.7 MB, and a client that missed them gets a snapshot. The protocol is unchanged. | | 2026-10-29 | minor | M8, #998: MCP tools that act on a document (`run_command`, `batch`, `create_node`, `set_properties`, `inspect_document`, `get_node`, `select`, `export`, `screenshot`, `save_file`, `undo`, `redo`) take an optional `document`, an open file's `id` (`doc.list` and the `document.inspect` outline now carry it) or path, and so do the control methods `engine.execute`, `document.inspect`, `node.get` and `ui.render`: the call acts on that file and the tab in front stays in front, so an attached agent keeps editing its own file when the user switches tabs. Calls without it act on the file in front as before; other control methods refuse a `document` behind the tab in front. Numbers unchanged | | 2026-10-29 | minor | Sibling placement in live sessions (#1065, from a security audit): every replica (the server's and each app's) placed a created, moved or reordered layer by counting each sibling that sorts before it, so a batch of many layers under one parent took quadratic time, on the server's single hub thread too. It now binary-searches the siblings' `(key, id)` order on each side of the layer's old place. The order is unchanged, ties and fractional positions included: a test replays 5,000 creations and 2,500 moves against the counting rule. 20,000 creations and 10,000 moves under two parents took 20 s in a debug build; they now take 0.3 s. | | 2026-10-29 | minor | Comments from an AI agent (M8, #1414): an agent attached over MCP commented, reacted and read threads as the user (the name from Preferences) unless it passed `author` on every call. Every command an MCP client runs now acts as that client's name from `initialize` (`clientInfo.title`, else `.name`) with " (agent)" after it, or "AI agent" without one, carried to the app as `engine.execute {agent}`; its messages, reactions and read state are its own, and its messages are marked (`agent: true` in the file and in `comment.list`, an Agent label by the name in the thread). The user's name and the comments made in the app are unchanged, and in a live session the name and marker travel with the thread to everyone. Tests in `comment_agent_tests`, the MCP `agent_comments` and attached suites, the UI comments suite and the collab diff round trip. Numbers unchanged | diff --git a/docs/server.md b/docs/server.md index a4c79ee3..a88d3a3c 100644 --- a/docs/server.md +++ b/docs/server.md @@ -288,6 +288,7 @@ are errors): | `max_people` | 256 | Most people in one file at once | | `compact_after` | 1000 | Compact a file's log into a new snapshot after this many entries… | | `compact_mb` | 16 | …or once its log is this large (MiB) | +| `max_log_mb` | 64 | Most MiB of recent batches a file in use keeps in memory (and in its snapshot) for apps that reconnect; an app further behind catches up with the whole file. Open to the public, at most a quarter of `max_file_mb` too | | `max_plugins` | 500 | Most plugin versions the plugin directory keeps (all plugins together); 0 turns the directory off | | `max_plugin_mb` | 16 | Largest plugin bundle, in MiB (at most 32) | | `max_plugins_mb` | 1024 | Most MiB of plugin bundles kept | @@ -491,7 +492,7 @@ are larger. | Resource | Limit | |---|---| | Message size | A file's size and 1 MiB (`max_file_mb`, 16 by default when open, or `max_plugin_mb` if larger): checked as the message is read, before anything is decoded; 64 KiB on a connection that only browses plugins (a client sending more is disconnected with the limit as the reason; the app keeps its own copy). On a private server, the larger of that and the core's 64 MiB | -| A file as it grows | Every batch is checked first (*always*): past `max_file_mb`, past its publisher's `max_mb_per_ip` or past `max_total_mb`, it is refused with the reason and the app rolls it back. A long log is compacted to make room at most once a minute per file (a compaction rewrites the whole file). A file's snapshot also holds the recent batches kept for reconnecting clients, at most a quarter of `max_file_mb` | +| A file as it grows | Every batch is checked first (*always*): past `max_file_mb`, past its publisher's `max_mb_per_ip` or past `max_total_mb`, it is refused with the reason and the app rolls it back. A long log is compacted to make room at most once a minute per file (a compaction rewrites the whole file). A file's snapshot also holds the recent batches kept for reconnecting clients, at most `max_log_mb` and a quarter of `max_file_mb` | | Recent batches in memory | A quarter of `max_file_mb` (256 KiB at least) per open file; older ones are dropped (a client that missed them catches up with the whole file) | | Bytes queued for slow clients | The largest message and 1 MiB per connection, 32 MiB per block of addresses, 256 MiB for everyone. A message sent to many people counts once for everyone and once per block (twenty colleagues behind one office address cost their block one copy of a broadcast), and welcomes (bounded by the file's size) don't count against blocks, so a whole office can join a file at once. A client that doesn't read its answers is dropped; when everyone's budget runs out, the connection holding the most is dropped | | Bytes being read | Messages not yet whole: the largest message and 1 MiB per block, 192 MiB for everyone; past everyone's budget the readers holding the most are cut off until it fits (their bytes count as free at once), and a reader is refused only when it is the biggest | From 33ddb4401674bde2da27288525f51b3a9197c021 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:11:48 +0100 Subject: [PATCH 24/63] Resolve stored variable ids by exact id, not by variable name Fixes #1513 Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/styles.rs | 8 ++++- crates/doc/src/tests.rs | 36 +++++++++++++++++++ crates/engine/src/cmd/styles.rs | 2 +- crates/engine/src/library_variables_tests.rs | 8 +++-- crates/engine/src/variable_props_tests.rs | 11 +++--- crates/engine/tests/it/main.rs | 2 ++ .../engine/tests/it/variable_binding_ids.rs | 32 +++++++++++++++++ crates/format/src/penpot.rs | 18 +++++----- 8 files changed, 98 insertions(+), 19 deletions(-) create mode 100644 crates/engine/tests/it/variable_binding_ids.rs diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index a9cad14a..92ea24cc 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -268,6 +268,12 @@ impl Variables { self.variables.iter().find(|v| v.id == id || v.name == id) } + /// Look up an already resolved variable id (a stored binding or alias): exact ids only, so a + /// variable named like an id never stands in for it. + pub fn get_by_id(&self, id: &str) -> Option<&Variable> { + self.variables.iter().find(|v| v.id == id) + } + pub fn collection(&self, id: &str) -> Option<&Collection> { self.collections.iter().find(|c| c.id == id || c.name == id) } @@ -277,7 +283,7 @@ impl Variables { pub fn resolve(&self, id: &str, modes: &BTreeMap) -> Option { let mut cur = id.to_string(); for _ in 0..32 { - let var = self.get(&cur)?; + let var = self.get_by_id(&cur)?; let coll = self.collection(&var.collection)?; let mode = modes.get(&coll.id).filter(|m| var.values.contains_key(*m)).or_else(|| coll.default_mode())?; match var.values.get(mode).or_else(|| var.values.values().next())? { diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 70dcd25b..0ed0ef35 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -310,6 +310,42 @@ fn variables_resolve_modes_and_aliases() { assert_eq!(v.resolve("loop", &std::collections::BTreeMap::new()), None); } +#[test] +fn variables_resolve_stored_ids_never_by_name() { + let mut v = Variables::default(); + v.collections.push(Collection { + id: "c1".into(), + name: "Theme".into(), + modes: vec![Mode { id: "m".into(), name: "Mode 1".into() }], + extends: None, + }); + let var = |id: &str, name: &str, value: VariableValue| Variable { + id: id.into(), + name: name.into(), + collection: "c1".into(), + kind: VariableType::Color, + values: std::collections::BTreeMap::from([("m".to_string(), value)]), + description: String::new(), + scopes: None, + hidden_from_publishing: false, + extends: None, + overrides: Default::default(), + code_syntax: Default::default(), + }; + let red = Color::from_hex("#FF0000").unwrap(); + let blue = Color::from_hex("#0D99FF").unwrap(); + // Named like the next variable's id, and like an id that no longer exists. + v.variables.push(var("VariableID:1", "VariableID:2", VariableValue::Color(red))); + v.variables.push(var("VariableID:2", "surface", VariableValue::Color(blue))); + v.variables.push(var("VariableID:3", "VariableID:9", VariableValue::Color(red))); + v.variables.push(var("VariableID:4", "alias", VariableValue::Alias("VariableID:9".into()))); + let modes = std::collections::BTreeMap::new(); + assert_eq!(v.resolve("VariableID:2", &modes), Some(VariableValue::Color(blue))); + // A deleted id stays unresolved instead of picking up the variable named like it. + assert_eq!(v.resolve("VariableID:9", &modes), None); + assert_eq!(v.resolve("VariableID:4", &modes), None); +} + #[test] fn serde_roundtrip_and_repair() { let mut d = Document::new("x"); diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index 0f3fdef6..c25fda80 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -784,7 +784,7 @@ pub fn bind(s: &mut Session, p: &Value) -> Result { pub(crate) fn bind_variable(cmd: &str, doc: &mut Document, ids: &[NodeId], field: &str, vid: Option<&str>) -> Result<()> { // An instance's component property takes a variable of its type; other fields the types their // scopes belong to. - if let Some(v) = vid.and_then(|v| doc.variables.get(v)) { + if let Some(v) = vid.and_then(|v| doc.variables.get_by_id(v)) { 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))?, diff --git a/crates/engine/src/library_variables_tests.rs b/crates/engine/src/library_variables_tests.rs index f61bab29..a811806e 100644 --- a/crates/engine/src/library_variables_tests.rs +++ b/crates/engine/src/library_variables_tests.rs @@ -171,7 +171,9 @@ fn library_update_follows(s: &mut Session, lib: &mut Session, local: &str, [inne // Same copies (same ids), new values: the bound layers and the alias follow. assert_eq!(copy_of(s, "primary").id, local); assert_eq!((fill(s, inner), fill(s, outer)), ("222222".into(), "FF0000".into())); - assert!(matches!(s.doc().unwrap().variables.resolve("brand", &modes), Some(VariableValue::Color(c)) if c.to_hex() == "00FFFF")); + assert!( + matches!(s.doc().unwrap().variables.resolve(&s.doc().unwrap().variables.get("brand").unwrap().id, &modes), Some(VariableValue::Color(c)) if c.to_hex() == "00FFFF") + ); assert_eq!(s.doc().unwrap().variables.collections[0].modes[1].name, "Night"); assert_eq!(ex(s, "library.updates", json!({}))["count"], 0); // Undo takes the update back. @@ -234,7 +236,9 @@ fn bind_alias_mode_read_only_and_updates() { ex(&mut s, "variable.create", json!({"collection": "App", "name": "gap", "type": "number", "value": 2})); ex(&mut s, "library.aliasVariable", json!({"library": lid, "alias": "accent", "variable": "brand"})); let modes = Default::default(); - assert!(matches!(s.doc().unwrap().variables.resolve("brand", &modes), Some(VariableValue::Color(c)) if c.to_hex() == "00FF00")); + assert!( + matches!(s.doc().unwrap().variables.resolve(&s.doc().unwrap().variables.get("brand").unwrap().id, &modes), Some(VariableValue::Color(c)) if c.to_hex() == "00FF00") + ); assert!(s.execute("library.aliasVariable", &json!({"library": lid, "alias": "primary", "variable": "gap"})).is_err(), "types differ"); assert!(s.execute("library.aliasVariable", &json!({"library": lid, "alias": "primary", "variable": local})).is_err(), "copies are read-only"); diff --git a/crates/engine/src/variable_props_tests.rs b/crates/engine/src/variable_props_tests.rs index 0d4790d5..2bcb12d5 100644 --- a/crates/engine/src/variable_props_tests.rs +++ b/crates/engine/src/variable_props_tests.rs @@ -373,17 +373,14 @@ fn variable_keys_inside_typed_paint_lists_stay_literal_but_bare_wrappers_error() } #[test] -fn variable_bind_keeps_its_legacy_type_lookup_when_a_name_collides_with_an_id() { +fn variable_bind_type_checks_the_found_variable_when_a_name_collides_with_an_id() { let mut s = Session::new(); run(&mut s, "variable.createCollection", json!({"name": "Theme"})); let number = run(&mut s, "variable.create", json!({"collection": "Theme", "name": "size", "type": "number", "value": 20}))["id"].clone(); let paint = run(&mut s, "variable.create", json!({"collection": "Theme", "name": "surface", "type": "color", "value": "#FF0000"}))["id"].clone(); run(&mut s, "variable.update", json!({"variable": number, "name": paint})); let id = create(&mut s, json!({})); - let before = s.doc().unwrap().clone(); - let depth = s.undo_depth(); - let e = s.execute("variable.bind", &json!({"id": id, "field": "fill", "variable": "surface"})).unwrap_err().to_string(); - assert!(e.contains("`fill` doesn't take a number variable"), "{e}"); - assert_eq!(s.doc().unwrap(), &before); - assert_eq!(s.undo_depth(), depth); + run(&mut s, "variable.bind", json!({"id": id, "field": "fill", "variable": "surface"})); + assert_eq!(s.doc().unwrap().node(id).unwrap().bound.get("fill").map(String::as_str), paint.as_str()); + assert_eq!(color(&s, id, "fill").to_hex(), "FF0000"); } diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 34a12847..7138d434 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -14,3 +14,5 @@ mod scope_reset; mod selection_colors; #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod shape_builder_response; +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod variable_binding_ids; diff --git a/crates/engine/tests/it/variable_binding_ids.rs b/crates/engine/tests/it/variable_binding_ids.rs new file mode 100644 index 00000000..cb8b3ab6 --- /dev/null +++ b/crates/engine/tests/it/variable_binding_ids.rs @@ -0,0 +1,32 @@ +use figcraft_engine::Session; +use serde_json::{Value, json}; + +/// Variable A is renamed to variable B's id; returns (session, B's id). +fn renamed_to_other_id(a_type: &str, a_value: Value) -> (Session, String) { + let mut s = Session::new(); + s.execute("variable.createCollection", &json!({"name": "Theme"})).unwrap(); + let a = s.execute("variable.create", &json!({"collection": "Theme", "name": "shadow", "type": a_type, "value": a_value})).unwrap(); + let a = a["id"].as_str().unwrap().to_string(); + let b = s.execute("variable.create", &json!({"collection": "Theme", "name": "surface", "type": "color", "value": "#0D99FF"})).unwrap(); + let b = b["id"].as_str().unwrap().to_string(); + s.execute("variable.update", &json!({"variable": a, "name": b})).unwrap(); + (s, b) +} + +#[test] +fn a_bound_id_wins_over_a_variable_named_like_it() { + let (mut s, b) = renamed_to_other_id("color", json!("#FF0000")); + let r = s.execute("node.create", &json!({"type": "rectangle", "props": {"fill": {"$var": b}}})).unwrap(); + let node = s.execute("node.get", &json!({"id": r["id"]})).unwrap(); + let text = node.to_string(); + assert!(text.contains(&format!("\"boundVariables\":{{\"fill\":\"{b}\"}}")), "{text}"); + assert!(text.contains("#0D99FF"), "{text}"); + assert!(!text.contains("#FF0000"), "{text}"); +} + +#[test] +fn the_type_check_uses_the_bound_id() { + let (mut s, b) = renamed_to_other_id("number", json!(4)); + let r = s.execute("node.create", &json!({"type": "rectangle", "props": {"fill": {"$var": b}}})); + assert!(r.is_ok(), "{r:?}"); +} diff --git a/crates/format/src/penpot.rs b/crates/format/src/penpot.rs index 554c23b1..bc60f809 100644 --- a/crates/format/src/penpot.rs +++ b/crates/format/src/penpot.rs @@ -5955,12 +5955,14 @@ mod tests { assert_eq!(doc.variables.collections.len(), 1); assert_eq!(doc.variables.variables.len(), 5); let values = &doc.variables; - assert!(matches!(values.resolve("Core/color.primary", &BTreeMap::new()), Some(VariableValue::Color(_)))); - assert_eq!(values.resolve("Core/spacing.sm", &BTreeMap::new()), Some(VariableValue::Number(8.0))); - assert!(matches!(values.resolve("Core/copy.cta", &BTreeMap::new()), Some(VariableValue::String(ref s)) if s == "Buy")); - assert_eq!(values.resolve("Core/flags.enabled", &BTreeMap::new()), Some(VariableValue::Boolean(true))); + assert!(matches!(values.resolve(&values.get("Core/color.primary").unwrap().id, &BTreeMap::new()), Some(VariableValue::Color(_)))); + assert_eq!(values.resolve(&values.get("Core/spacing.sm").unwrap().id, &BTreeMap::new()), Some(VariableValue::Number(8.0))); + assert!( + matches!(values.resolve(&values.get("Core/copy.cta").unwrap().id, &BTreeMap::new()), Some(VariableValue::String(ref s)) if s == "Buy") + ); + assert_eq!(values.resolve(&values.get("Core/flags.enabled").unwrap().id, &BTreeMap::new()), Some(VariableValue::Boolean(true))); assert!(warnings.iter().any(|s| s.contains("themes"))); - assert!(matches!(values.resolve("Core/aliases.secondary", &BTreeMap::new()), Some(VariableValue::Color(_)))); + assert!(matches!(values.resolve(&values.get("Core/aliases.secondary").unwrap().id, &BTreeMap::new()), Some(VariableValue::Color(_)))); assert!(!warnings.iter().any(|s| s.contains("unsupported or unresolved"))); let page = doc.node(doc.first_page().unwrap()).unwrap(); assert_eq!( @@ -6118,9 +6120,9 @@ mod tests { assert_eq!(doc.variables.variables.len(), 7); let v = &doc.variables; assert!(matches!(v.get("Core/palette.middle").unwrap().values.values().next(), Some(VariableValue::Alias(_)))); - assert!(matches!(v.resolve("Core/palette.accent", &BTreeMap::new()), Some(VariableValue::Color(_)))); - assert_eq!(v.resolve("Core/space.copy", &BTreeMap::new()), Some(VariableValue::Number(12.0))); - assert_eq!(v.resolve("Core/labelAlias", &BTreeMap::new()), Some(VariableValue::String("Button".into()))); + assert!(matches!(v.resolve(&v.get("Core/palette.accent").unwrap().id, &BTreeMap::new()), Some(VariableValue::Color(_)))); + assert_eq!(v.resolve(&v.get("Core/space.copy").unwrap().id, &BTreeMap::new()), Some(VariableValue::Number(12.0))); + assert_eq!(v.resolve(&v.get("Core/labelAlias").unwrap().id, &BTreeMap::new()), Some(VariableValue::String("Button".into()))); assert!(!warnings.iter().any(|w| w.contains("unsupported or unresolved")), "{warnings:?}"); let saved = crate::save_bytes(&doc, None).unwrap(); let (reloaded, issues) = crate::load_bytes(&saved).unwrap(); From dd65d7f4d20cbdb15a271249df2711ec448608fe Mon Sep 17 00:00:00 2001 From: Carbocode Date: Thu, 29 Oct 2026 14:19:04 +0100 Subject: [PATCH 25/63] Refresh local craft-libs lockfile entries --- Cargo.lock | 3 --- 1 file changed, 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 335b0d62..3aa21e65 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1010,7 +1010,6 @@ dependencies = [ [[package]] name = "craft-keymap" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "egui", "serde", @@ -1020,7 +1019,6 @@ dependencies = [ [[package]] name = "craft-menu" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "craft-keymap", "craft-quit", @@ -1033,7 +1031,6 @@ dependencies = [ [[package]] name = "craft-quit" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "dispatch2", "egui", From 3108d5bee71a389c13e31a310d26da4291d4c170 Mon Sep 17 00:00:00 2001 From: BFlat Date: Thu, 29 Oct 2026 20:34:30 +0700 Subject: [PATCH 26/63] M8: edit.duplicate returns an id map with map: true (#995) (#1528) With map: true, edit.duplicate also returns map: each original layer's id, descendants included (nested frames, groups, instances and their sublayers), paired with its copy's id, so a batch can edit inside a copy through a step reference instead of inspecting it. Without map the result is unchanged. --- ROADMAP.md | 3 +- crates/engine/src/cmd/edit.rs | 7 +- crates/engine/src/duplicate_map_tests.rs | 131 +++++++++++++++++++++++ crates/engine/src/lib.rs | 2 + crates/mcp/src/tools.rs | 2 +- docs/mcp.md | 14 +++ docs/roadmap.md | 3 +- 7 files changed, 158 insertions(+), 4 deletions(-) create mode 100644 crates/engine/src/duplicate_map_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 7fe8ffb9..cf7c7cb6 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-29 · **Change:** minor (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-10-29 · **Change:** minor (`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 @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Which copy is which (M8, #995): `edit.duplicate` with `map: true` also returns `map`, each original layer's id, descendants included (nested frames, groups, instances and their sublayers), paired with its copy's id, so a batch can edit inside a copy through a step reference (`"field": "map/6"`) instead of inspecting it. Without `map` the result is unchanged. Regression tests in `crates/engine/src/duplicate_map_tests.rs`. Numbers unchanged | | 2026-10-29 | minor | Bounded replay log (#1069, from a security audit): the batches a session keeps for apps that reconnect were bounded only by count (10,000 batches, 1,000,000 changes) on a desktop host and a private `figcraft-server`, so a long busy session could hold any number of bytes. `Limits::max_log_bytes` now defaults to 64 MiB, and `figcraft-server` takes it from a new `max_log_mb` setting (default 64; open to the public, also at most a quarter of `max_file_mb`, as before). An app further behind than the trimmed log catches up with the whole file, as it already did past the count limits. Nine 8 MiB renames with default limits kept all 75.5 MB before; now 58.7 MB, and a client that missed them gets a snapshot. The protocol is unchanged. | | 2026-10-29 | minor | M8, #998: MCP tools that act on a document (`run_command`, `batch`, `create_node`, `set_properties`, `inspect_document`, `get_node`, `select`, `export`, `screenshot`, `save_file`, `undo`, `redo`) take an optional `document`, an open file's `id` (`doc.list` and the `document.inspect` outline now carry it) or path, and so do the control methods `engine.execute`, `document.inspect`, `node.get` and `ui.render`: the call acts on that file and the tab in front stays in front, so an attached agent keeps editing its own file when the user switches tabs. Calls without it act on the file in front as before; other control methods refuse a `document` behind the tab in front. Numbers unchanged | | 2026-10-29 | minor | Sibling placement in live sessions (#1065, from a security audit): every replica (the server's and each app's) placed a created, moved or reordered layer by counting each sibling that sorts before it, so a batch of many layers under one parent took quadratic time, on the server's single hub thread too. It now binary-searches the siblings' `(key, id)` order on each side of the layer's old place. The order is unchanged, ties and fractional positions included: a test replays 5,000 creations and 2,500 moves against the counting rule. 20,000 creations and 10,000 moves under two parents took 20 s in a debug build; they now take 0.3 s. | diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 1b9466e2..3c4fa125 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -111,7 +111,7 @@ pub fn specs() -> Vec { spec!( "edit.duplicate", "Duplicate", - "{ids?, dx?, dy? (page offset; default: the offset of the last duplicate when ids are its copies, still selected; else top-level frames to the right with a 100 gap, everything else in place)} a main component's copy is an instance of it (a variant stays a new variant in its set)", + "{ids?, dx?, dy? (page offset; default: the offset of the last duplicate when ids are its copies, still selected; else top-level frames to the right with a 100 gap, everything else in place), map? (true: also return `map`, each original layer's id, descendants included, → its copy's id)} a main component's copy is an instance of it (a variant stays a new variant in its set)", true, duplicate ), @@ -632,6 +632,11 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { crate::board::rejoin(doc, &crate::board::copied_items(doc, &map), &map, false); s.set_selection(out.clone()); s.ds_mut()?.last_duplicate = Some(Duplicated { sources: ids, copies: out.clone() }); + if boolean(p, "map") == Some(true) { + // JSON object keys are strings: each original layer's id → its copy's. + let map: Map = map.into_iter().map(|(o, c)| (o.to_string(), json!(c))).collect(); + return Ok(json!({"ids": out, "map": map})); + } Ok(json!({"ids": out})) } diff --git a/crates/engine/src/duplicate_map_tests.rs b/crates/engine/src/duplicate_map_tests.rs new file mode 100644 index 00000000..c31015f2 --- /dev/null +++ b/crates/engine/src/duplicate_map_tests.rs @@ -0,0 +1,131 @@ +//! `edit.duplicate` with `map: true` (#995): every original layer, descendants included, paired +//! with its copy, so a caller can edit inside a copy without inspecting it. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; +use figcraft_doc::{Document, NodeId}; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn idv(v: &Value) -> NodeId { + id_of(&v["id"]).unwrap() +} + +/// A "Screen" frame holding an instance of a "Card" component (with a "Label" sublayer), a group +/// of two rectangles and a nested frame with a text; returns (screen, card). +fn screen(s: &mut Session) -> (NodeId, NodeId) { + let card = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 80, "height": 40, "name": "Card"}))); + ex(s, "text.create", json!({"text": "Hi", "x": 4, "y": 4, "parent": card, "props": {"name": "Label"}})); + let card = idv(&ex(s, "component.create", json!({"ids": [card]}))); + let scr = idv(&ex(s, "frame.create", json!({"x": 200, "y": 0, "width": 300, "height": 300, "name": "Screen"}))); + ex(s, "instance.create", json!({"component": card, "x": 210, "y": 10, "parent": scr})); + let r = |s: &mut Session, y: f64| idv(&ex(s, "shape.rectangle", json!({"x": 210, "y": y, "width": 20, "height": 20, "parent": scr}))); + let (a, b) = (r(s, 100.0), r(s, 130.0)); + ex(s, "object.group", json!({"ids": [a, b]})); + let inner = idv(&ex(s, "frame.create", json!({"x": 250, "y": 100, "width": 100, "height": 100, "parent": scr, "name": "Inner"}))); + ex(s, "text.create", json!({"text": "Body", "x": 260, "y": 110, "parent": inner})); + (scr, card) +} + +/// The child indexes from `root` down to `id`. +fn path(doc: &Document, root: NodeId, id: NodeId) -> Vec { + let mut out = Vec::new(); + let mut at = id; + while at != root { + out.push(doc.index_in_parent(at).unwrap()); + at = doc.node(at).unwrap().parent.unwrap(); + } + out.reverse(); + out +} + +fn pairs(out: &Value) -> Vec<(NodeId, NodeId)> { + out["map"].as_object().unwrap().iter().map(|(k, v)| (NodeId(k.parse().unwrap()), id_of(v).unwrap())).collect() +} + +/// The map pairs every layer of each original's subtree with the layer at the same place in its +/// copy, of the same type and name. +fn assert_complete(s: &Session, roots: &[(NodeId, NodeId)], out: &Value) { + let doc = s.doc().unwrap(); + let map = pairs(out); + let all: usize = roots.iter().map(|(o, _)| doc.subtree(*o).len()).sum(); + assert_eq!(map.len(), all, "every original layer, descendants included, is in the map: {out}"); + for (o, c) in map { + let (root_o, root_c) = roots.iter().find(|(r, _)| doc.subtree(*r).contains(&o)).copied().unwrap(); + assert!(doc.subtree(root_c).contains(&c), "{c} is inside the copy of {root_o}"); + assert_eq!(path(doc, root_o, o), path(doc, root_c, c), "{c} sits where {o} does"); + let (a, b) = (doc.node(o).unwrap(), doc.node(c).unwrap()); + assert_eq!(a.name, b.name, "{o} → {c}"); + // A main component's copy is an instance of it (the tests check the roots' types). + if o != root_o { + assert_eq!(a.kind.type_name(), b.kind.type_name(), "{o} → {c}"); + } + } +} + +#[test] +fn map_pairs_nested_frames_groups_and_instances_with_their_copies() { + let mut s = Session::new(); + let (scr, _) = screen(&mut s); + let out = ex(&mut s, "edit.duplicate", json!({"ids": [scr], "map": true})); + let copy = id_of(&out["ids"][0]).unwrap(); + assert_eq!(out["map"][scr.to_string()], json!(copy), "the root maps to the copy in `ids`"); + assert_complete(&s, &[(scr, copy)], &out); +} + +#[test] +fn map_covers_several_roots_and_a_main_component_copied_as_an_instance() { + let mut s = Session::new(); + let (scr, card) = screen(&mut s); + let out = ex(&mut s, "edit.duplicate", json!({"ids": [card, scr], "map": true})); + let ids: Vec = out["ids"].as_array().unwrap().iter().map(|v| id_of(v).unwrap()).collect(); + assert_eq!(ids.len(), 2); + let (card_copy, scr_copy) = (out["map"][card.to_string()].clone(), out["map"][scr.to_string()].clone()); + assert!(ids.contains(&id_of(&card_copy).unwrap()) && ids.contains(&id_of(&scr_copy).unwrap()), "{out}"); + let doc = s.doc().unwrap(); + let card_copy = id_of(&card_copy).unwrap(); + assert_eq!(doc.node(card_copy).unwrap().kind.type_name(), "INSTANCE", "a main component's copy is an instance"); + assert_complete(&s, &[(card, card_copy), (scr, id_of(&scr_copy).unwrap())], &out); +} + +#[test] +fn without_map_the_result_is_just_the_ids() { + let mut s = Session::new(); + let (scr, _) = screen(&mut s); + for p in [json!({"ids": [scr]}), json!({"ids": [scr], "map": false})] { + let out = ex(&mut s, "edit.duplicate", p); + let keys: Vec<&String> = out.as_object().unwrap().keys().collect(); + assert_eq!(keys, ["ids"], "{out}"); + } +} + +#[test] +fn a_batch_edits_inside_the_copy_through_the_map() { + let mut s = Session::new(); + let (scr, _) = screen(&mut s); + let doc = s.doc().unwrap(); + let inner = *doc.node(scr).unwrap().children.last().unwrap(); + let body = doc.node(inner).unwrap().children[0]; + let field = format!("map/{body}"); + ex( + &mut s, + "command.batch", + json!({"commands": [ + {"command": "edit.duplicate", "as": "copy", "params": {"ids": [scr], "map": true}}, + {"command": "node.set", "params": {"ids": [{"$ref": "copy", "field": field}], "props": {"text": "Copy"}}}, + ]}), + ); + let doc = s.doc().unwrap(); + let text = |id: NodeId| match &doc.node(id).unwrap().kind { + figcraft_doc::NodeKind::Text(t) => t.characters.clone(), + _ => String::new(), + }; + assert_eq!(text(body), "Body", "the original is untouched"); + let copy = s.selection()[0]; + let copy_body = doc.subtree(copy).into_iter().find(|i| path(doc, copy, *i) == path(doc, scr, body)).unwrap(); + assert_eq!(text(copy_body), "Copy", "the edit landed in the copy"); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e7bddd7d..872f3cd3 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -263,6 +263,8 @@ mod double_click_tests; #[cfg(test)] mod draw_tools_tests; #[cfg(test)] +mod duplicate_map_tests; +#[cfg(test)] mod duplicate_spacing_tests; #[cfg(test)] mod effects_tests; diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 6adbbd11..e82c30c8 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -160,7 +160,7 @@ fn tools() -> Vec { tool( "batch", "Run commands as one step", - "Run several commands in order as ONE undo step (engine `command.batch`). By default (onError: \"abort\"), if any command fails, everything is rolled back and the error names the failing step; success returns an array of the results in order. With onError: \"continue\", a failed step is rolled back on its own, the later steps still run, the call is not an error, and the array instead holds one {step, command, ok, result} or {step, command, ok, error} object per step; a reference to a failed step fails that step too. A later step can use what an earlier step returned: name a step with \"as\": \"card\", then anywhere in a later step's params {\"$ref\": \"card\"} stands for the `id` in its result ({\"$step\": n} does the same by zero-based position). Add \"field\" for another part of the result: a key or a slash path, e.g. {\"$ref\": \"copy\", \"field\": \"ids/0\"} for the first id edit.duplicate returned. Any object with a `$ref` or `$step` key is a reference. Example: [{\"command\":\"node.create\",\"as\":\"card\",\"params\":{\"type\":\"frame\"}}, {\"command\":\"node.create\",\"params\":{\"type\":\"text\",\"text\":\"Hi\",\"parent\":{\"$ref\":\"card\"}}}] builds a frame and a text inside it in one call.", + "Run several commands in order as ONE undo step (engine `command.batch`). By default (onError: \"abort\"), if any command fails, everything is rolled back and the error names the failing step; success returns an array of the results in order. With onError: \"continue\", a failed step is rolled back on its own, the later steps still run, the call is not an error, and the array instead holds one {step, command, ok, result} or {step, command, ok, error} object per step; a reference to a failed step fails that step too. A later step can use what an earlier step returned: name a step with \"as\": \"card\", then anywhere in a later step's params {\"$ref\": \"card\"} stands for the `id` in its result ({\"$step\": n} does the same by zero-based position). Add \"field\" for another part of the result: a key or a slash path, e.g. {\"$ref\": \"copy\", \"field\": \"ids/0\"} for the first id edit.duplicate returned (with \"map\": true it also returns `map`, each original id → its copy, so \"field\": \"map/3\" is the copy of layer 3). Any object with a `$ref` or `$step` key is a reference. Example: [{\"command\":\"node.create\",\"as\":\"card\",\"params\":{\"type\":\"frame\"}}, {\"command\":\"node.create\",\"params\":{\"type\":\"text\",\"text\":\"Hi\",\"parent\":{\"$ref\":\"card\"}}}] builds a frame and a text inside it in one call.", obj( json!({ "commands": {"type": "array", "minItems": 1, "description": "Steps to run", "items": obj(json!({"command": string("Command id"), "params": {"type": "object"}, "as": string("Name for this step, unique in the batch; later steps use its result with {\"$ref\": name}")}), &["command"])}, diff --git a/docs/mcp.md b/docs/mcp.md index 8f53f818..a2afc7e9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2062,6 +2062,20 @@ the two can be mixed. Names are any non-empty string and must be unique within t {"command":"node.set","params":{"ids":[{"$ref":"copy","field":"ids/0"}],"props":{"name":"Card copy"}}}]}}} ``` +To edit inside a copy, pass `"map": true`: `edit.duplicate` then also returns `map`, each original +layer's id (as a string, since JSON keys are strings), descendants included, paired with its copy's +id, e.g. `{"ids": [8], "map": {"5": 8, "6": 9, "7": 10}}`. Nested frames, groups and instances (and +their sublayers) are in it; a main component's copy is an instance of it, so its sublayers pair with +the instance's. So `"field": "map/6"` is the copy of layer 6: + +```json +{"jsonrpc":"2.0","id":11,"method":"tools/call","params":{"name":"batch","arguments":{"label":"Copy","commands":[ + {"command":"edit.duplicate","as":"copy","params":{"ids":[5],"map":true}}, + {"command":"node.set","params":{"ids":[{"$ref":"copy","field":"map/6"}],"props":{"text":"Copy title"}}}]}}} +``` + +Without `map` the result is `{"ids": [...]}` as before. + A path may start with `/`, as in JSON Pointer (`/ids/0`). A key that itself holds `/` is matched as written before it is read as a path. A path ends at whatever value it reaches, so `"field": "ids"` is the whole list. diff --git a/docs/roadmap.md b/docs/roadmap.md index 62e6780e..2503af35 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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-10-28 · **Change:** minor (`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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Which copy is which (M8, #995): `edit.duplicate` with `map: true` also returns `map`, each original layer's id, descendants included (nested frames, groups, instances and their sublayers), paired with its copy's id, so a batch can edit inside a copy through a step reference (`"field": "map/6"`) instead of inspecting it. Without `map` the result is unchanged. Regression tests in `crates/engine/src/duplicate_map_tests.rs`. Numbers unchanged | | 2026-10-29 | minor | Bounded replay log (#1069, from a security audit): the batches a session keeps for apps that reconnect were bounded only by count (10,000 batches, 1,000,000 changes) on a desktop host and a private `figcraft-server`, so a long busy session could hold any number of bytes. `Limits::max_log_bytes` now defaults to 64 MiB, and `figcraft-server` takes it from a new `max_log_mb` setting (default 64; open to the public, also at most a quarter of `max_file_mb`, as before). An app further behind than the trimmed log catches up with the whole file, as it already did past the count limits. Nine 8 MiB renames with default limits kept all 75.5 MB before; now 58.7 MB, and a client that missed them gets a snapshot. The protocol is unchanged. | | 2026-10-29 | minor | M8, #998: MCP tools that act on a document (`run_command`, `batch`, `create_node`, `set_properties`, `inspect_document`, `get_node`, `select`, `export`, `screenshot`, `save_file`, `undo`, `redo`) take an optional `document`, an open file's `id` (`doc.list` and the `document.inspect` outline now carry it) or path, and so do the control methods `engine.execute`, `document.inspect`, `node.get` and `ui.render`: the call acts on that file and the tab in front stays in front, so an attached agent keeps editing its own file when the user switches tabs. Calls without it act on the file in front as before; other control methods refuse a `document` behind the tab in front. Numbers unchanged | | 2026-10-29 | minor | Sibling placement in live sessions (#1065, from a security audit): every replica (the server's and each app's) placed a created, moved or reordered layer by counting each sibling that sorts before it, so a batch of many layers under one parent took quadratic time, on the server's single hub thread too. It now binary-searches the siblings' `(key, id)` order on each side of the layer's old place. The order is unchanged, ties and fractional positions included: a test replays 5,000 creations and 2,500 moves against the counting rule. 20,000 creations and 10,000 moves under two parents took 20 s in a debug build; they now take 0.3 s. | From 6d8b0e68f0a252d3f899930566c9fd8e7cfc3908 Mon Sep 17 00:00:00 2001 From: samhyun Date: Thu, 29 Oct 2026 14:11:29 +0900 Subject: [PATCH 27/63] M8: the browser draws a zoom's full render a band at a time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once a zoom had paused, the browser drew its full render in one frame. On a heavy page that frame took seconds, and no input was handled meanwhile: the wheel steps and drags that came were seen only when it was over. The renderer's `Retained::begin` hands a full render back as `Steps`, drawn `band_h` rows at a time with the same `render_band` and `put_band` the desktop's parallel bands use (bands overlap by what background blurs read and keep their own rows; a blur reaching past a band makes the bands taller rather than one piece), and `finish` keeps the image as a full render. A render that is reused or patched is done at once, as before. The browser's `Inline` worker refines through `Renders::refine_for`, which draws at least a band a frame, more while they fit in `STEP_MS` (24 ms), and asks for the next frame while bands remain, so input is handled between them; a view has at most eight bands, so on a page whose render takes seconds the longest frame is about an eighth of it. A render drawn for the very request is done once drawn. A pan or an edit meanwhile at the same zoom keeps the render going and is patched in once it is drawn; a pan too far to patch or an edit the patch can't take draws it again in steps, the image just drawn showing till then (`Refined::Shown`); a zoom, another page or file, another size or other render options drop the render for one of the new view, and what its bands took stands as the measure the next zoom's preview and wait go by. A render in steps counts as one render (glass keeps the shapes a later render asks for again). The desktop, with its render thread, is unchanged. Measured in Chrome on two synthetic pages (20 images and 60 shadowed rectangles, with and without two background-blur panels) whose full render takes 3.6–4.0 s at a deep zoom: the longest frame while that render runs went from 3.7–4.0 s to 0.9–1.4 s (blur) and 0.69–0.80 s (no blur); the bands together take longer than one render (the refined image comes after 6.7–8.1 s and 5.3–5.5 s instead of 3.8–4.1 s), each band rendering its overlap again and repeating some per-band work, shadows mostly. The zoom steps are as before. Co-Authored-By: Claude Fable 5.1 --- crates/render/src/lib.rs | 71 ++++-- crates/render/src/retained.rs | 137 ++++++++++- crates/render/src/retained_tests.rs | 58 +++++ crates/ui-egui/src/canvas/render_worker.rs | 222 ++++++++++++++++-- .../ui-egui/src/canvas/zoom_preview_tests.rs | 195 +++++++++++++-- docs/gaps.md | 9 +- 6 files changed, 625 insertions(+), 67 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index a357033d..69a7a1cc 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -25,7 +25,7 @@ pub mod shadow; pub use dots::Dots; pub use draw::{node_outline, paint_bounds, stroke_fill, subtree_outline, ts_path}; pub use export_depth::{ExportDepthError, MAX_EXPORT_DEPTH, check_export_depth}; -pub use retained::{Retained, Work}; +pub use retained::{Begun, Retained, Steps, Work}; pub use tiny_skia::{FillRule, Pixmap}; use figcraft_color::{Color, ColorProfile}; @@ -88,7 +88,7 @@ pub struct Options { pub dots: Option, } -fn new_pixmap(w: u32, h: u32) -> Option { +pub(crate) fn new_pixmap(w: u32, h: u32) -> Option { let w = w.clamp(1, 16_384); let h = h.clamp(1, 16_384); Pixmap::new(w, h) @@ -111,7 +111,7 @@ pub(crate) fn backdrop_reach(radius: f64, scale: f64) -> f64 { static RENDERS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); /// The number of the render that just started. -fn start_render() -> u64 { +pub(crate) fn start_render() -> u64 { RENDERS.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1 } @@ -145,42 +145,61 @@ pub fn render_view(doc: &Document, page: NodeId, view: &View, opts: &Options) -> fn render_bands(doc: &Document, page: NodeId, view: &View, opts: &Options, bands: u32) -> Option { use rayon::prelude::*; let band_h = view.height.div_ceil(bands.max(1)); - let overlap = backdrop_reach(max_backdrop_blur(doc), view.scale).ceil(); - if !overlap.is_finite() || overlap > f64::from(band_h) { - return None; - } - let overlap = overlap as u32; - let s = view.scale.max(1e-9); + let overlap = band_overlap(doc, view, band_h)?; let parts: Vec<(u32, Option)> = (0..bands) .into_par_iter() .map(|i| { let y0 = i * band_h; - let h = band_h.min(view.height.saturating_sub(y0)).max(1); - let top = y0.saturating_sub(overlap); - let bottom = (y0 + h + overlap).min(view.height).max(y0 + h); - let v = View { origin: (view.origin.0, view.origin.1 + f64::from(top) / s), scale: view.scale, width: view.width, height: bottom - top }; - let part = render_one(doc, page, &v, opts); - let own = tiny_skia::IntRect::from_xywh(0, (y0 - top) as i32, view.width.clamp(1, 16_384), h); - (y0, if overlap == 0 { part } else { part.zip(own).and_then(|(p, r)| p.clone_rect(r)) }) + (y0, render_band(doc, page, view, opts, y0, band_h, overlap)) }) .collect(); let mut out = new_pixmap(view.width, view.height)?; - let row = out.width() as usize * 4; for (y0, part) in parts { - let Some(part) = part else { continue }; - // Bands are as wide as the view: their rows go straight into place. - let start = y0 as usize * row; - let dst = out.data_mut(); - let n = part.data().len().min(dst.len().saturating_sub(start)); - if part.width() == view.width.clamp(1, 16_384) - && let (Some(d), Some(s)) = (dst.get_mut(start..start + n), part.data().get(..n)) - { - d.copy_from_slice(s); + if let Some(part) = part { + put_band(&mut out, y0, &part); } } Some(out) } +/// How many rows bands of `band_h` rows of `view` overlap (what background blurs read); `None` +/// when that is more than a band. +pub(crate) fn band_overlap(doc: &Document, view: &View, band_h: u32) -> Option { + let overlap = backdrop_reach(max_backdrop_blur(doc), view.scale).ceil(); + (overlap.is_finite() && overlap <= f64::from(band_h)).then_some(overlap as u32) +} + +/// The rows `y0 .. y0 + band_h` of `view` (fewer at the bottom), rendered with `overlap` rows +/// more each way and cut back to their own. +pub(crate) fn render_band(doc: &Document, page: NodeId, view: &View, opts: &Options, y0: u32, band_h: u32, overlap: u32) -> Option { + let s = view.scale.max(1e-9); + let h = band_h.min(view.height.saturating_sub(y0)).max(1); + let top = y0.saturating_sub(overlap); + let bottom = (y0 + h + overlap).min(view.height).max(y0 + h); + let v = View { origin: (view.origin.0, view.origin.1 + f64::from(top) / s), scale: view.scale, width: view.width, height: bottom - top }; + let part = render_one(doc, page, &v, opts); + if overlap == 0 { + return part; + } + let own = tiny_skia::IntRect::from_xywh(0, (y0 - top) as i32, view.width.clamp(1, 16_384), h); + part.zip(own).and_then(|(p, r)| p.clone_rect(r)) +} + +/// Copy a band's rows into `out` at row `y0` (bands are as wide as the view: their rows go +/// straight into place). +pub(crate) fn put_band(out: &mut Pixmap, y0: u32, part: &Pixmap) { + let row = out.width() as usize * 4; + let start = y0 as usize * row; + let width = out.width(); + let dst = out.data_mut(); + let n = part.data().len().min(dst.len().saturating_sub(start)); + if part.width() == width + && let (Some(d), Some(s)) = (dst.get_mut(start..start + n), part.data().get(..n)) + { + d.copy_from_slice(s); + } +} + /// Render a page region on the calling thread. pub fn render_view_single(doc: &Document, page: NodeId, view: &View, opts: &Options) -> Option { start_render(); diff --git a/crates/render/src/retained.rs b/crates/render/src/retained.rs index 620b8f56..046ed842 100644 --- a/crates/render/src/retained.rs +++ b/crates/render/src/retained.rs @@ -127,6 +127,70 @@ impl Px { } } +/// What [`Retained::prepare`] found. +enum Prepared { + /// The previous render patched (or reused as is), now kept. + Patched(View, Work), + /// Only a full render will do: the snapped view, its top-left device pixel, the font + /// generation and what [`sensitive`] found when the previous render's document was checked. + Full { snapped: View, px: (i64, i64), fonts: u64, sensitive: Option> }, +} + +/// What [`Retained::begin`] did. +pub enum Begun<'a> { + /// The view drawn, its image and what it took. + Done(View, &'a Pixmap, Work), + /// A full render to draw band by band (boxed: it holds the image). + Steps(Box), +} + +/// A full render drawn a band at a time ([`Retained::begin`]): bands overlap by what background +/// blurs read and keep only their own rows, as the desktop's parallel bands do. +pub struct Steps { + doc: Arc, + page: NodeId, + view: View, + px: (i64, i64), + fonts: u64, + opts: Options, + sensitive: Option>, + pix: Pixmap, + /// The first row not drawn yet. + next: u32, + band_h: u32, + overlap: u32, +} + +impl Steps { + /// Draw the next band; `true` once every row is drawn. + pub fn step(&mut self) -> bool { + if self.done() { + return true; + } + let y0 = self.next; + if let Some(part) = crate::render_band(&self.doc, self.page, &self.view, &self.opts, y0, self.band_h, self.overlap) { + crate::put_band(&mut self.pix, y0, &part); + } + self.next = y0.saturating_add(self.band_h); + self.done() + } + + /// Whether every row is drawn. + pub fn done(&self) -> bool { + self.next >= self.view.height + } + + /// The view drawn (snapped to device pixels). + pub fn view(&self) -> &View { + &self.view + } + + /// The page drawn. + pub fn page(&self) -> NodeId { + self.page + } +} + /// Keeps the last rendered view; see the module docs. #[derive(Default)] pub struct Retained { @@ -142,13 +206,73 @@ impl Retained { /// Render `view` of `page`, reusing the previous render where nothing changed. Returns the /// view actually drawn (snapped to device pixels, see [`snap`]), the image and what it took. pub fn render(&mut self, doc: &Arc, page: NodeId, view: &View, opts: &Options) -> Option<(View, &Pixmap, Work)> { + match self.prepare(doc, page, view, opts)? { + Prepared::Patched(snapped, work) => Some((snapped, &self.last.as_ref()?.pix, work)), + Prepared::Full { snapped, px, fonts, sensitive } => { + let pix = render_view(doc, page, &snapped, opts)?; + let s = sensitive.unwrap_or_else(|| Arc::new(self::sensitive(doc))); + let frame = self.keep(doc, page, (&snapped, px, fonts), opts, pix, s); + Some((snapped, &frame.pix, Work::Full)) + } + } + } + + /// [`Self::render`], except that a full render comes back as [`Steps`] to draw `band_h` rows + /// at a time (the browser, whose renders block the page: input is handled between bands) and + /// keep with [`Self::finish`]. A render that is reused or patched is done at once. + pub fn begin(&mut self, doc: &Arc, page: NodeId, view: &View, opts: &Options, band_h: u32) -> Option> { + match self.prepare(doc, page, view, opts)? { + Prepared::Patched(snapped, work) => Some(Begun::Done(snapped, &self.last.as_ref()?.pix, work)), + Prepared::Full { snapped, px, fonts, sensitive } => { + // Bands at least twice what they overlap (each draws its overlap again both ways), + // so a background blur reaching past `band_h` makes the bands taller. + let overlap = crate::band_overlap(doc, &snapped, u32::MAX).map(|o| o.min(snapped.height)); + let band_h = band_h.max(1).max(overlap.unwrap_or(0).saturating_mul(2)); + // A blur that can't be measured: one piece. + let Some(overlap) = overlap else { + let pix = render_view(doc, page, &snapped, opts)?; + let s = sensitive.unwrap_or_else(|| Arc::new(self::sensitive(doc))); + let frame = self.keep(doc, page, (&snapped, px, fonts), opts, pix, s); + return Some(Begun::Done(snapped, &frame.pix, Work::Full)); + }; + let pix = crate::new_pixmap(snapped.width, snapped.height)?; + // One render, as the desktop's parallel bands are: glass keeps the shapes a + // later render asks for again, and the bands of this one are not later renders. + crate::start_render(); + Some(Begun::Steps(Box::new(Steps { + doc: doc.clone(), + page, + view: snapped, + px, + fonts, + opts: opts.clone(), + sensitive, + pix, + next: 0, + band_h, + overlap, + }))) + } + } + } + + /// Keep the image `steps` drew (all its bands: see [`Steps::step`]) as a full render. + pub fn finish(&mut self, steps: Steps) -> (View, &Pixmap) { + let Steps { doc, page, view, px, fonts, opts, sensitive, pix, .. } = steps; + let s = sensitive.unwrap_or_else(|| Arc::new(self::sensitive(&doc))); + let frame = self.keep(&doc, page, (&view, px, fonts), &opts, pix, s); + (view, &frame.pix) + } + + /// The previous render patched where it could be (kept, see [`Self::keep`]), or what a full + /// render of `view` needs. + fn prepare(&mut self, doc: &Arc, page: NodeId, view: &View, opts: &Options) -> Option { // A view that can't be put on a pixel grid (non-finite or absurd) draws nothing. let Some((snapped, px)) = snap(view) else { self.last = None; return None; }; let fonts = figcraft_text::layout_generation(); - let mut work = Work::Full; let mut sens = None; if let Some(last) = self.last.take() && last.page == page @@ -173,16 +297,13 @@ impl Retained { let same_doc = Arc::ptr_eq(&last.doc, doc); let s = if same_doc { last.sensitive.clone() } else { Arc::new(sensitive(doc)) }; if let Some((pix, pixels)) = patch(last, doc, page, &snapped, px, opts, &s) { - work = if pixels == 0 { Work::Reused } else { Work::Partial { pixels } }; - let frame = self.keep(doc, page, (&snapped, px, fonts), opts, pix, s); - return Some((snapped, &frame.pix, work)); + let work = if pixels == 0 { Work::Reused } else { Work::Partial { pixels } }; + self.keep(doc, page, (&snapped, px, fonts), opts, pix, s); + return Some(Prepared::Patched(snapped, work)); } sens = Some(s); } - let pix = render_view(doc, page, &snapped, opts)?; - let s = sens.unwrap_or_else(|| Arc::new(sensitive(doc))); - let frame = self.keep(doc, page, (&snapped, px, fonts), opts, pix, s); - Some((snapped, &frame.pix, work)) + Some(Prepared::Full { snapped, px, fonts, sensitive: sens }) } /// Keep `pix`, the render of `at`: the snapped view, its top-left device pixel and the font diff --git a/crates/render/src/retained_tests.rs b/crates/render/src/retained_tests.rs index 958da373..89b7b7a1 100644 --- a/crates/render/src/retained_tests.rs +++ b/crates/render/src/retained_tests.rs @@ -287,3 +287,61 @@ fn overflowing_text_is_drawn_while_its_box_is_out_of_view() { let dark = pix.data().as_chunks::<4>().0.iter().filter(|c| c[0] < 128).count(); assert!(dark > 20, "glyphs drawn: {dark}"); } + +/// Draw a full render through [`Retained::begin`] `band_h` rows at a time; how many steps it took. +fn stepped(r: &mut Retained, doc: &Arc, page: NodeId, v: &View, band_h: u32) -> (View, Pixmap, usize) { + match r.begin(doc, page, v, &Options::default(), band_h).unwrap() { + crate::Begun::Done(shown, pix, work) => { + assert_eq!(work, Work::Full, "one piece only when a blur can't be measured"); + (shown, pix.clone(), 0) + } + crate::Begun::Steps(mut steps) => { + let mut n = 1; + while !steps.step() { + n += 1; + assert!(n < 1000, "steps end"); + } + let (shown, pix) = r.finish(*steps); + (shown, pix.clone(), n) + } + } +} + +#[test] +fn a_render_drawn_band_by_band_matches_one_in_one_piece() { + for cut_sensitive in [false, true] { + let s = scene(cut_sensitive); + // The whole scene in view, so the glass's backdrop is never cut by the view. + let v = View { origin: (-10.0, -10.0), scale: 1.0, width: 380, height: 360 }; + let mut r = Retained::default(); + let (shown, pix, n) = stepped(&mut r, &s.doc, s.page, &v, 40); + assert_eq!(n, shown.height.div_ceil(40) as usize, "a band a step"); + let full = render_view(&s.doc, s.page, &shown, &Options::default()).unwrap(); + assert_same(&pix, &full, "bands"); + // Kept as a full render: the next pan patches it. + assert!(partial(check(&mut r, &s.doc, s.page, &View { origin: (0.0, -10.0), ..v }))); + } +} + +#[test] +fn bands_thinner_than_a_blur_reaches_grow_to_twice_its_reach() { + let s = scene(true); + let v = View { origin: (-10.0, -10.0), scale: 4.0, width: 380, height: 360 }; + let reach = crate::band_overlap(&s.doc, &v, u32::MAX).unwrap(); + assert!(reach > 4, "the blur reaches past the bands asked for"); + let mut r = Retained::default(); + let (shown, pix, n) = stepped(&mut r, &s.doc, s.page, &v, 4); + assert_eq!(n, shown.height.div_ceil(4.max(reach.min(shown.height) * 2)) as usize, "bands twice the reach"); + assert_same(&pix, &render_view(&s.doc, s.page, &shown, &Options::default()).unwrap(), "taller bands"); +} + +#[test] +fn a_reused_or_patched_render_needs_no_steps() { + let s = scene(false); + let v = view(0.0, 0.0, 1.0); + let mut r = Retained::default(); + check(&mut r, &s.doc, s.page, &v); + assert!(matches!(r.begin(&s.doc, s.page, &v, &Options::default(), 40), Some(crate::Begun::Done(_, _, Work::Reused)))); + let panned = View { origin: (12.0, 0.0), ..v }; + assert!(matches!(r.begin(&s.doc, s.page, &panned, &Options::default(), 40), Some(crate::Begun::Done(_, _, Work::Partial { .. })))); +} diff --git a/crates/ui-egui/src/canvas/render_worker.rs b/crates/ui-egui/src/canvas/render_worker.rs index 94b2d3d8..0ffdd34f 100644 --- a/crates/ui-egui/src/canvas/render_worker.rs +++ b/crates/ui-egui/src/canvas/render_worker.rs @@ -15,13 +15,16 @@ //! The browser renders on the UI thread, so a zoom's full render blocks the page: there a preview //! is refined only once no newer request has come for [`SETTLE_MS`] (the zoom has paused), or for //! as long as the last full render took when that is shorter (a wait never costs more than the -//! render it may save). +//! render it may save), and the full render is then drawn a band at a time +//! ([`figcraft_render::Steps`]), at least a band a frame and more while they fit in [`STEP_MS`], +//! so input is handled while it draws (on a page whose render takes seconds, a band is a +//! fraction of that: eight at most). use std::sync::Arc; use figcraft_color::ColorProfile; use figcraft_doc::{Document, NodeId}; -use figcraft_render::{Pixmap, Retained, View, Work}; +use figcraft_render::{Begun, Pixmap, Retained, Steps, View, Work}; // A monotonic clock in the browser too: preview decisions rest on how long renders take there. use web_time::Instant; @@ -33,6 +36,16 @@ pub const PREVIEW_AFTER_MS: f64 = 12.0; /// long as the last full render took, when shorter): a full render there blocks the page, and one /// between every two wheel steps of a zoom held it up for seconds. pub const SETTLE_MS: u64 = 120; +/// The browser draws a full render a band at a time ([`figcraft_render::Steps`]): at least one +/// band a frame, and more while they fit in this long, so input is handled while it draws. A +/// band of a render that takes seconds takes a fraction of that (see [`MAX_BANDS`]). +pub const STEP_MS: f64 = 24.0; +/// Bands are at least this many rows ... +const MIN_BAND_ROWS: u32 = 64; +/// ... and a view has at most this many: the longest frame of a render drawn in steps is about a +/// band's, an eighth of the render plus its overlap (more bands would repeat more of the overlap +/// and of the per-band work). +const MAX_BANDS: u32 = 8; #[derive(Clone)] pub struct Job { @@ -100,11 +113,9 @@ fn mark_outside_srgb(image: &mut egui::ColorImage, pix: &Pixmap, profile: ColorP } } -/// Render `view` of the job (its own view, or the coarse one standing in for it). -fn run(job: &Job, view: &View, retained: &mut Retained, coarse: bool) -> Option { - let t0 = Instant::now(); - // The canvas is a preview: what's too small to see in detail at this zoom is approximated. - let opts = figcraft_render::Options { +/// The canvas is a preview: what's too small to see in detail at this zoom is approximated. +fn options(job: &Job) -> figcraft_render::Options { + figcraft_render::Options { outline_mode: job.outline, outline: job.outline_extras, outlined: job.outlined.clone(), @@ -114,14 +125,23 @@ fn run(job: &Job, view: &View, retained: &mut Retained, coarse: bool) -> Option< magnified_image_pixels: true, dots: job.dots, ..Default::default() - }; - let (view, pix, work) = retained.render(&job.doc, job.page, view, &opts)?; + } +} + +/// Render `view` of the job (its own view, or the coarse one standing in for it). +fn run(job: &Job, view: &View, retained: &mut Retained, coarse: bool) -> Option { + let t0 = Instant::now(); + let (view, pix, work) = retained.render(&job.doc, job.page, view, &options(job))?; + Some(done(job, view, pix, work, t0.elapsed().as_secs_f64() * 1000.0, coarse)) +} + +/// The image `pix` of `view`, rendered for `job` in `ms`. +fn done(job: &Job, view: View, pix: &Pixmap, work: Work, ms: f64, coarse: bool) -> Done { let mut image = screen_image(pix, job.doc.color_profile); if job.proof { mark_outside_srgb(&mut image, pix, job.doc.color_profile); } - let ms = t0.elapsed().as_secs_f64() * 1000.0; - Some(Done { image, view, ms, work, uid: job.uid, page: job.page, coarse, at: job.at, seq: 0 }) + Done { image, view, ms, work, uid: job.uid, page: job.page, coarse, at: job.at, seq: 0 } } /// `view` with [`PREVIEW_FACTOR`] times fewer pixels each way, showing at least the same area. @@ -141,6 +161,56 @@ pub struct Renders { full_ms: f64, /// Previews come first only when renders from scratch take at least this long. preview_after_ms: f64, + /// A full render being drawn band by band (the browser): the job it was started for, the + /// font generation then, the bands and the time they took so far. + steps: Option, +} + +/// A full render drawn in steps and what it is of: a newer job of the same file, page, zoom, size +/// and render options keeps it going (its image is then patched to the job, as a pan or an edit +/// is), any other drops it. +struct Stepping { + job: Job, + fonts: u64, + steps: Box, + spent: f64, +} + +/// Whether `a` and `b` are drawn the same way: the same file, page, zoom, size and options (the +/// view may be elsewhere and the document edited: a render of one is patched to the other). +fn same_settings(a: &Job, b: &Job) -> bool { + a.uid == b.uid + && a.page == b.page + && a.view.scale.to_bits() == b.view.scale.to_bits() + && (a.view.width, a.view.height) == (b.view.width, b.view.height) + && a.outline == b.outline + && a.outline_extras == b.outline_extras + && a.outlined == b.outlined + && a.skip == b.skip + && a.solo == b.solo + && a.proof == b.proof + && a.dots == b.dots +} + +/// Whether a render drawn for `a` (with fonts of generation `fonts`) is `b`'s image as it is: +/// the same settings, document and view, and no fonts arrived meanwhile. +fn same_request(a: &Job, fonts: u64, b: &Job) -> bool { + same_settings(a, b) + && Arc::ptr_eq(&a.doc, &b.doc) + && a.view.origin.0.to_bits() == b.view.origin.0.to_bits() + && a.view.origin.1.to_bits() == b.view.origin.1.to_bits() + && figcraft_text::layout_generation() == fonts +} + +/// What [`Renders::refine_for`] got to. +pub enum Refined { + /// The image (`None`: nothing could be drawn). + Done(Option), + /// Bands remain: call again (next frame). + More, + /// A full render drawn for what has changed since (an edit, a pan or images that came while + /// it was drawn), shown meanwhile; it is drawn again: call again. + Shown(Done), } impl Default for Renders { @@ -152,7 +222,7 @@ impl Default for Renders { impl Renders { /// Previews when renders from scratch take at least `ms` (tests: 0, always). pub fn with_threshold(ms: f64) -> Self { - Self { full: Retained::default(), preview: Retained::default(), last: None, full_ms: 0.0, preview_after_ms: ms } + Self { full: Retained::default(), preview: Retained::default(), last: None, full_ms: 0.0, preview_after_ms: ms, steps: None } } /// Whether `job` is a render from scratch: the kept image is of another zoom, page or file, or @@ -164,7 +234,13 @@ impl Renders { /// Whether `job` gets a preview first: it renders from scratch and those have been slow on a /// page already drawn (the first render has no measure). fn wants_preview(&self, job: &Job) -> bool { - job.zoom_preview && self.last.is_some() && self.full_ms >= self.preview_after_ms && self.starts_over(job) + job.zoom_preview && self.last.is_some() && self.full_ms_so_far() >= self.preview_after_ms && self.starts_over(job) + } + + /// How long the last render from scratch took, or what the one being drawn in steps has taken + /// so far when that is more: a render dropped mid-way (a zoom going on) leaves a measure too. + pub fn full_ms_so_far(&self) -> f64 { + self.full_ms.max(self.steps.as_ref().map_or(0.0, |s| s.spent)) } /// Whether `job` should get a preview should its render run long: from scratch, with none first. @@ -191,6 +267,107 @@ impl Renders { } done } + + /// [`Self::refine`] for about `budget_ms` at a time (the browser): a full render is drawn band by + /// band over several calls, the kept image (re-projected) or the preview showing meanwhile. + pub fn refine_for(&mut self, job: &Job, budget_ms: f64) -> Refined { + let t0 = Instant::now(); + if let Some(s) = self.steps.as_ref() + && !same_settings(&s.job, job) + { + // Dropped for a render of the new view. What its bands took is at least what a full + // render takes: enough for the next zoom to get its preview and its wait. + self.full_ms = self.full_ms.max(s.spent); + self.steps = None; + } + if self.steps.is_none() + && let Some(r) = self.begin(job, t0, None) + { + return r; + } + let Some(s) = self.steps.as_mut() else { return Refined::Done(None) }; + let steps = &mut s.steps; + let stepped = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + loop { + if steps.step() { + return true; + } + if t0.elapsed().as_secs_f64() * 1000.0 >= budget_ms { + return false; + } + } + })); + s.spent += t0.elapsed().as_secs_f64() * 1000.0; + match stepped { + Ok(false) => Refined::More, + Ok(true) => { + let Some(Stepping { job: drawn_for, fonts, steps, spent }) = self.steps.take() else { return Refined::Done(None) }; + let (view, pix) = self.full.finish(*steps); + self.full_ms = spent; + // The image of the job it was started for: its document's colour profile, its + // proof setting and its request time. + let drawn = done(&drawn_for, view, pix, Work::Full, spent, false); + // Drawn for this very request: shown, done. Otherwise kept, then started on like + // any render: an edit, a pan or images that came meanwhile are patched in, or it + // is drawn again in steps (never all at once here), this one shown till then. + if same_request(&drawn_for, fonts, job) { + return Refined::Done(self.finished(job, Some(drawn))); + } + self.begin(job, t0, Some(drawn)).unwrap_or(Refined::More) + } + Err(_) => { + self.steps = None; + self.full.clear(); + Refined::Done(None) + } + } + } + + /// Start `job` on the kept image: done when it is reused or patched, `None` when a full render + /// is now drawn in steps. `drawn` is the kept image just drawn in steps: what a reuse shows, + /// else shown while it is drawn again. + fn begin(&mut self, job: &Job, t0: Instant, mut drawn: Option) -> Option { + // The snapped view may be a row taller. + let rows = job.view.height.saturating_add(1).div_ceil(MAX_BANDS).max(MIN_BAND_ROWS); + let begun = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match self.full.begin(&job.doc, job.page, &job.view, &options(job), rows) { + Some(Begun::Done(view, pix, work)) => Err(Box::new(match drawn.take() { + Some(d) if work == Work::Reused => Some(d), + _ => Some(done(job, view, pix, work, t0.elapsed().as_secs_f64() * 1000.0, false)), + })), + Some(Begun::Steps(steps)) => Ok(steps), + None => Err(Box::new(None)), + })); + match begun { + Ok(Ok(steps)) => { + // A pan or an edit at this zoom builds on this render: no preview for it. + self.last = Some((job.uid, job.page, job.view.scale)); + self.steps = Some(Stepping { job: job.clone(), fonts: figcraft_text::layout_generation(), steps, spent: 0.0 }); + drawn.map(Refined::Shown) + } + Ok(Err(d)) => Some(Refined::Done(self.finished(job, *d))), + Err(_) => { + self.full.clear(); + Some(Refined::Done(None)) + } + } + } + + /// Record a render done at once in [`Self::refine_for`]. + fn finished(&mut self, job: &Job, d: Option) -> Option { + if let Some(d) = &d { + self.last = Some((job.uid, job.page, job.view.scale)); + if d.work == Work::Full { + self.full_ms = d.ms; + } + } + d + } + + /// Whether a full render is being drawn band by band. + pub fn stepping(&self) -> bool { + self.steps.is_some() + } } /// The coarse preview of the whole of `job`'s view, kept in `retained`. @@ -401,7 +578,7 @@ impl Inline { if let Some(d) = self.pending.as_ref().and_then(|job| self.renders.preview(job)) { self.preview_shown = true; self.since = Instant::now(); - let full = std::time::Duration::try_from_secs_f64(self.renders.full_ms / 1000.0); + let full = std::time::Duration::try_from_secs_f64(self.renders.full_ms_so_far() / 1000.0); self.wait = full.map_or(self.settle, |full| full.min(self.settle)); self.ctx.request_repaint_after(self.wait); return Some(answering(d, self.submitted)); @@ -414,8 +591,21 @@ impl Inline { return None; } } - let job = self.pending.take()?; - self.renders.refine(&job).map(|d| answering(d, self.submitted)) + let job = self.pending.as_ref()?; + match self.renders.refine_for(job, STEP_MS) { + Refined::Done(d) => { + self.pending = None; + d.map(|d| answering(d, self.submitted)) + } + Refined::More => { + self.ctx.request_repaint(); + None + } + Refined::Shown(d) => { + self.ctx.request_repaint(); + Some(answering(d, self.submitted)) + } + } } } diff --git a/crates/ui-egui/src/canvas/zoom_preview_tests.rs b/crates/ui-egui/src/canvas/zoom_preview_tests.rs index 68b11870..321016a3 100644 --- a/crates/ui-egui/src/canvas/zoom_preview_tests.rs +++ b/crates/ui-egui/src/canvas/zoom_preview_tests.rs @@ -18,7 +18,7 @@ use super::{CanvasState, shows}; use figcraft_render::Retained; use crate::render_worker::{ - Done, Inline, Job, PREVIEW_AFTER_MS, PREVIEW_FACTOR, Renders, SETTLE_MS, Worker, better, coarse_view, preview_with, render_now, + Done, Inline, Job, PREVIEW_AFTER_MS, PREVIEW_FACTOR, Refined, Renders, SETTLE_MS, Worker, better, coarse_view, preview_with, render_now, }; /// A page of ellipses with a rectangle on top; the session and the rectangle. @@ -90,6 +90,45 @@ fn same_pixels(a: &Done, b: &Done) -> bool { a.image.size == b.image.size && a.image.pixels == b.image.pixels } +/// The same image up to where render bands meet ([`figcraft_render::Steps`] and the desktop's +/// parallel bands cut the view in different places): on each row, under a quarter of a level on +/// average and at most one pixel in twenty off. +fn close_pixels(a: &Done, b: &Done) -> bool { + let [w, h] = a.image.size; + a.image.size == b.image.size + && (0..h).all(|y| { + let row = |d: &Done| d.image.pixels[y * w..(y + 1) * w].to_vec(); + let (ra, rb) = (row(a), row(b)); + let diffs: Vec = + ra.iter().zip(&rb).map(|(p, q)| p.to_array().iter().zip(q.to_array()).map(|(x, y)| u32::from(x.abs_diff(y))).sum()).collect(); + let sum: u32 = diffs.iter().sum(); + f64::from(sum) / (w as f64 * 4.0) < 0.25 && diffs.iter().filter(|d| **d > 0).count() * 20 <= w + }) +} + +/// Poll the browser's worker until it shows the full image (drawn band by band over polls). +fn refined(w: &mut Inline) -> Option { + for _ in 0..1000 { + match w.poll() { + Some(d) => return Some(d), + None if !w.in_flight() => return None, + None => {} + } + } + None +} + +/// Refine `job` through `r` with no time to spare (a band a call); the image and how many calls. +fn stepped(r: &mut Renders, job: &Job) -> (Option, usize) { + for n in 1..1000 { + match r.refine_for(job, 0.0) { + Refined::Done(d) => return (d, n), + Refined::Shown(_) | Refined::More => {} + } + } + (None, 1000) +} + fn covers(outer: &View, inner: &View) -> bool { let (o, i) = (outer.page_rect(), inner.page_rect()); let eps = 1e-6; @@ -397,23 +436,23 @@ fn the_browser_worker_previews_by_measured_render_time_then_refines_the_next_fra // The production threshold, as the browser has it; refined the next frame (settling: below). let mut w = Inline::with_settle(egui::Context::default(), std::time::Duration::ZERO); w.submit(at(2.0)); - let first = w.poll().expect("the first image"); + let first = refined(&mut w).expect("the first image"); assert!(!first.coarse && !w.in_flight(), "the first render has no measure to preview by"); assert!(first.ms >= PREVIEW_AFTER_MS, "renders are timed in the browser worker too (and this one is slow): {} ms", first.ms); - // A new zoom: the preview this frame, the refined image the next. + // A new zoom: the preview this frame, the refined image over the next (a band each). w.submit(at(1.0)); let p = w.poll().expect("a preview"); assert!(p.coarse && w.in_flight()); - let d = w.poll().expect("the refined image"); + let d = refined(&mut w).expect("the refined image"); assert!(!d.coarse && !w.in_flight()); - assert!(same_pixels(&d, &render_now(&at(1.0)).unwrap())); + assert!(close_pixels(&d, &render_now(&at(1.0)).unwrap()), "drawn band by band"); // A newer request before the refinement: refining the old one is skipped. w.submit(at(0.5)); assert!(w.poll().is_some_and(|d| d.coarse)); w.submit(at(0.25)); let p = w.poll().expect("the newer one's preview"); assert!(p.coarse && (p.view.scale - 0.25 / PREVIEW_FACTOR).abs() < 1e-12); - assert!(w.poll().is_some_and(|d| !d.coarse && d.view.scale == 0.25)); + assert!(refined(&mut w).is_some_and(|d| !d.coarse && d.view.scale == 0.25)); assert!(w.poll().is_none() && !w.in_flight()); } @@ -426,7 +465,7 @@ fn the_browser_worker_refines_a_zoom_only_once_it_settles() { let settle = std::time::Duration::from_millis(SETTLE_MS); let mut w = Inline::with_settle(egui::Context::default(), settle); w.submit(at(2.0)); - assert!(w.poll().is_some_and(|d| !d.coarse), "the first render: no preview, no wait"); + assert!(refined(&mut w).is_some_and(|d| !d.coarse), "the first render: no preview, no wait"); // Zooming: each step shows its preview, never a full render in between. for scale in [1.5, 1.0] { w.submit(at(scale)); @@ -434,11 +473,11 @@ fn the_browser_worker_refines_a_zoom_only_once_it_settles() { assert!(w.poll().is_none() && w.in_flight(), "not refined while the zoom may go on"); } std::thread::sleep(settle); - let d = w.poll().expect("refined once it settled"); + let d = refined(&mut w).expect("refined once it settled"); assert!(!d.coarse && d.view.scale == 1.0 && !w.in_flight()); // A pan at the same zoom has no preview and is refined at once. w.submit(job(&ds.doc, ds.page, ds.uid, view_at((340.0, 200.0), 1.0, 1200, 1200))); - assert!(w.poll().is_some_and(|d| !d.coarse)); + assert!(refined(&mut w).is_some_and(|d| !d.coarse)); } /// The wait counts from the preview being shown, not from the request: rendering the preview can @@ -455,13 +494,13 @@ fn the_wait_counts_from_the_preview_not_from_the_request() { let settle = std::time::Duration::from_millis(SETTLE_MS); let mut w = Inline::with_settle(egui::Context::default(), settle); w.submit(at(2.0)); - assert!(w.poll().is_some_and(|d| !d.coarse)); + assert!(refined(&mut w).is_some_and(|d| !d.coarse)); w.submit(at(1.0)); std::thread::sleep(settle + std::time::Duration::from_millis(30)); assert!(w.poll().is_some_and(|d| d.coarse), "the preview, longer after the request than the wait"); assert!(w.poll().is_none() && w.in_flight(), "the wait starts only now"); std::thread::sleep(settle); - assert!(w.poll().is_some_and(|d| !d.coarse && d.view.scale == 1.0)); + assert!(refined(&mut w).is_some_and(|d| !d.coarse && d.view.scale == 1.0)); } /// Until the zoom's full render has run, the kept image is of the old zoom, so a pan or an edit @@ -477,7 +516,7 @@ fn same_zoom_requests_before_the_zoom_refines_keep_waiting_and_refine_at_once_af let settle = std::time::Duration::from_millis(SETTLE_MS); let mut w = Inline::with_settle(egui::Context::default(), settle); w.submit(at((300.0, 200.0), 2.0)); - assert!(w.poll().is_some_and(|d| !d.coarse), "the first render: no preview, no wait"); + assert!(refined(&mut w).is_some_and(|d| !d.coarse), "the first render: no preview, no wait"); // A zoom step, then a pan at the new zoom before the pause: a preview each, no full render. w.submit(at((300.0, 200.0), 1.0)); assert!(w.poll().is_some_and(|d| d.coarse)); @@ -486,11 +525,11 @@ fn same_zoom_requests_before_the_zoom_refines_keep_waiting_and_refine_at_once_af assert!(w.poll().is_some_and(|d| d.coarse), "from scratch still: the kept image is of the old zoom"); assert!(w.poll().is_none() && w.in_flight(), "the wait starts over"); std::thread::sleep(settle); - let d = w.poll().expect("refined once it settled"); + let d = refined(&mut w).expect("refined once it settled"); assert!(!d.coarse && d.view.scale == 1.0 && !w.in_flight()); // From then on a pan at this zoom builds on the kept image: no preview, refined at once. w.submit(at((380.0, 200.0), 1.0)); - let d = w.poll().expect("the refined image"); + let d = refined(&mut w).expect("the refined image"); assert!(!d.coarse && !w.in_flight()); } @@ -507,12 +546,136 @@ fn the_wait_is_capped_by_the_time_of_the_last_full_render() { let settle = std::time::Duration::from_secs(10); let mut w = Inline::with_settle(egui::Context::default(), settle); w.submit(at(2.0)); - let first = w.poll().expect("the first image"); + let first = refined(&mut w).expect("the first image"); assert!(!first.coarse && first.ms >= PREVIEW_AFTER_MS, "slow enough to preview: {} ms", first.ms); w.submit(at(1.0)); assert!(w.poll().is_some_and(|d| d.coarse)); assert!(w.poll().is_none() && w.in_flight(), "not refined in the very next frame"); std::thread::sleep(std::time::Duration::from_secs_f64(first.ms / 1000.0)); - let d = w.poll().expect("refined once the last full render's time has passed, not the settle"); + let d = refined(&mut w).expect("refined once the last full render's time has passed, not the settle"); assert!(!d.coarse && d.view.scale == 1.0 && !w.in_flight()); } + +#[test] +fn the_browser_draws_a_full_render_band_by_band() { + let view = |x| view_at((x, 200.0), 1.0, 600, 600); + let s = slow_scene(&[view(300.0)], 3.0 * PREVIEW_AFTER_MS); + let ds = s.ds().unwrap(); + let at = |x| job(&ds.doc, ds.page, ds.uid, view(x)); + let mut r = Renders::default(); + let (d, n) = stepped(&mut r, &at(300.0)); + let d = d.expect("drawn"); + assert!(n > 2 && !r.stepping(), "several calls, then done ({n})"); + assert!(close_pixels(&d, &render_now(&at(300.0)).unwrap())); + // Kept: a pan now patches it at once. + assert!(matches!(r.refine_for(&at(320.0), 0.0), Refined::Done(Some(d)) if d.work != Work::Full)); +} + +#[test] +fn a_pan_while_drawing_finishes_the_render_then_patches_it_and_a_zoom_drops_it() { + let view = |x, scale| view_at((x, 200.0), scale, 600, 600); + let s = slow_scene(&[view(300.0, 1.0)], 3.0 * PREVIEW_AFTER_MS); + let ds = s.ds().unwrap(); + let at = |x, scale| job(&ds.doc, ds.page, ds.uid, view(x, scale)); + let mut r = Renders::default(); + assert!(matches!(r.refine_for(&at(300.0, 1.0), 0.0), Refined::More)); + // Panned meanwhile: the same render goes on, and what comes is the panned view. + let (d, _) = stepped(&mut r, &at(330.0, 1.0)); + let d = d.expect("drawn"); + assert!(close_pixels(&d, &render_now(&at(330.0, 1.0)).unwrap()), "the panned view"); + // Zoomed meanwhile: the render under way is dropped for one of the new zoom. + let mut r = Renders::default(); + assert!(matches!(r.refine_for(&at(300.0, 1.0), 0.0), Refined::More)); + let (d, _) = stepped(&mut r, &at(300.0, 2.0)); + assert_eq!(d.expect("drawn").view.scale, 2.0); +} + +#[test] +fn an_edit_while_drawing_shows_in_what_comes() { + let v = view_at((300.0, 200.0), 1.0, 600, 600); + let mut s = slow_scene(std::slice::from_ref(&v), 3.0 * PREVIEW_AFTER_MS); + let (doc, page, uid) = { + let ds = s.ds().unwrap(); + (ds.doc.clone(), ds.page, ds.uid) + }; + let mut r = Renders::default(); + assert!(matches!(r.refine_for(&job(&doc, page, uid, v.clone()), 0.0), Refined::More)); + // Edited meanwhile, the view unchanged: the render goes on and what comes shows the edit. + s.execute("shape.rectangle", &json!({"x": 100, "y": 0, "width": 400, "height": 400})).unwrap(); + let edited = job(&s.ds().unwrap().doc, page, uid, v); + let (d, _) = stepped(&mut r, &edited); + assert!(close_pixels(&d.expect("drawn"), &render_now(&edited).unwrap()), "the edit shows"); +} + +#[test] +fn a_render_overtaken_by_a_long_pan_is_shown_then_drawn_again() { + let v = view_at((300.0, 200.0), 1.0, 600, 600); + let s = slow_scene(std::slice::from_ref(&v), 3.0 * PREVIEW_AFTER_MS); + let ds = s.ds().unwrap(); + let mut r = Renders::default(); + assert!(matches!(r.refine_for(&job(&ds.doc, ds.page, ds.uid, v.clone()), 0.0), Refined::More)); + // Panned more than a view meanwhile: nothing to patch. What was drawn shows, then the new view. + let far = job(&ds.doc, ds.page, ds.uid, view_at((1500.0, 200.0), 1.0, 600, 600)); + let mut shown = vec![]; + let last = (0..1000).find_map(|_| match r.refine_for(&far, 0.0) { + Refined::More => None, + Refined::Shown(d) => { + shown.push(d); + None + } + Refined::Done(d) => Some(d), + }); + assert_eq!(shown.len(), 1, "the render drawn shows once"); + assert!(close_pixels(&shown[0], &render_now(&job(&ds.doc, ds.page, ds.uid, v)).unwrap()), "the view it was drawn for"); + assert!(close_pixels(&last.flatten().expect("drawn"), &render_now(&far).unwrap()), "then the new view"); +} + +/// Drawn for the very request, the render is done: it is not started on again, even where the +/// kept image can never be patched (outline mode drawing hidden layers), which drew it again +/// and again, the page never idle. +#[test] +fn a_render_drawn_for_the_request_is_done_even_where_it_could_never_be_patched() { + let v = view_at((300.0, 200.0), 1.0, 600, 600); + let s = slow_scene(std::slice::from_ref(&v), 3.0 * PREVIEW_AFTER_MS); + let ds = s.ds().unwrap(); + let mut hidden = job(&ds.doc, ds.page, ds.uid, v); + hidden.outline = true; + hidden.outline_extras.hidden = true; + let mut r = Renders::default(); + let (d, n) = stepped(&mut r, &hidden); + assert!(d.is_some_and(|d| !d.coarse && d.work == Work::Full) && !r.stepping(), "done after {n} calls"); + assert!(n < 100, "drawn once, not over and over ({n} calls)"); +} + +/// A change of render options (outline mode here) mid-way drops the render, as a zoom does: the +/// bands of the old one are not drawn to the end first. +#[test] +fn a_change_of_options_while_drawing_drops_the_render() { + let v = view_at((300.0, 200.0), 1.0, 600, 600); + let s = slow_scene(std::slice::from_ref(&v), 3.0 * PREVIEW_AFTER_MS); + let ds = s.ds().unwrap(); + let plain = job(&ds.doc, ds.page, ds.uid, v.clone()); + let mut outlined = plain.clone(); + outlined.outline = true; + let (_, alone) = stepped(&mut Renders::default(), &outlined); + let mut r = Renders::default(); + assert!(matches!(r.refine_for(&plain, 0.0), Refined::More)); + let (d, n) = stepped(&mut r, &outlined); + assert!(d.is_some_and(|d| !d.coarse)); + assert!(n <= alone + 1, "the old render's bands are dropped: {n} calls against {alone} alone"); +} + +/// A render dropped mid-way leaves a measure behind: what its bands took is at least what a full +/// render takes, so the next zoom gets its preview (and, in the browser, its wait) instead of +/// starting from scratch at once, as many times as the zoom goes on. +#[test] +fn a_render_dropped_while_drawing_leaves_its_time_as_the_measure() { + let view = |scale| view_at((300.0, 200.0), scale, 600, 600); + let s = slow_scene(&[view(1.0)], 3.0 * PREVIEW_AFTER_MS); + let ds = s.ds().unwrap(); + let at = |scale| job(&ds.doc, ds.page, ds.uid, view(scale)); + // Previews once renders have taken any time at all: a band has, where no measure (0 ms) hasn't. + let mut r = Renders::with_threshold(1e-9); + assert!(matches!(r.refine_for(&at(1.0), 0.0), Refined::More)); + assert!(r.preview(&at(2.0)).is_some(), "the zoom gets a preview by the dropped render's measure"); +} diff --git a/docs/gaps.md b/docs/gaps.md index cac8392f..537b6315 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (`.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); Arabic draft catalog; gap 7: the browser refines a zoom's preview once the zoom has paused; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (gap 7: the browser draws a zoom's full render a band at a time; `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); Arabic draft catalog; gap 7: the browser refines a zoom's preview once the zoom has paused; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -273,6 +273,12 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe sixteenth of the pixels first, then refined. With no measure to go by (the first render, another page, a zoom after quick renders), a watchdog thread previews any such render still running after 12 ms (desktop only; the browser renders inline). +- **Landed (2026-10-29):** the browser draws a zoom's full render a band at a time (`Retained::begin`, + `Steps`; at most eight bands, so the longest frame is about an eighth of the render where it was + the whole of it), so input is handled while it draws: a pan or an edit meanwhile is patched into + the render once drawn (or it is drawn again, the first shown till then), a zoom drops it. Bands + overlap by what background blurs read, as the desktop's parallel bands do, and the bands together + take longer than one render (each repeats its overlap and some per-band work). - **Landed (2026-10-28):** the browser refines a zoom's preview only once no newer request has come for 120 ms (`SETTLE_MS`), or for as long as its last full render took when that is shorter: its full renders run on the UI thread, and one between every two wheel steps held the page up @@ -555,6 +561,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Gap 7: the browser draws a zoom's full render a band at a time (eight at most), so a deep zoom no longer holds the page for the whole render; pans and edits meanwhile are patched in once it is drawn. Numbers unchanged | | 2026-10-28 | minor | Arabic draft: 1,857 UI strings, 620 command labels, 31 menu wordings; count-neutral plurals and web font gate. Native review and panel mirroring remain open; grade and estimates unchanged. Evidence: `locales/ar.toml`, i18n catalog tests and [translation notes](translating.md#arabic-draft) | | 2026-10-28 | minor | Gap 7: the browser refines a zoom's preview only once the zoom has paused for 120 ms, so a wheel zoom no longer runs a blocking full render between every two steps. Numbers unchanged | | 2026-10-27 | minor | M12: the MCP agent feedback workflow landed (comments as tasks, `comment.prompt`, Copy for AI, `fix_comments` / `watch_comments`, `figcraft://comments`, a bounded event log, `events.poll` and `wait_for_changes`). Figma AI features and local models are still not started, so gap 19's missing list and estimate stand | From 0c7e8626b40c05af6a6b7c673ce3479773da1289 Mon Sep 17 00:00:00 2001 From: Carbocode Date: Thu, 29 Oct 2026 14:40:44 +0100 Subject: [PATCH 28/63] M1: dismiss colour pickers outside and anchor them beside the sidebar --- ROADMAP.md | 3 +- crates/ui-egui/src/app/frame.rs | 11 ++- .../src/colorpicker_selection_tests.rs | 6 +- crates/ui-egui/src/dialogs/plugins_ui.rs | 4 +- crates/ui-egui/src/dialogs/variables_panel.rs | 2 +- crates/ui-egui/src/field_tests.rs | 16 +++-- crates/ui-egui/src/panel_remove_tests.rs | 69 ++++++++++++++++--- crates/ui-egui/src/panels/right.rs | 2 +- crates/ui-egui/src/panels/right/effects.rs | 2 +- crates/ui-egui/src/panels/right/paints.rs | 8 +-- crates/ui-egui/src/panels/selection_colors.rs | 6 +- crates/ui-egui/src/popover.rs | 6 +- crates/ui-egui/src/widgets/colorpicker.rs | 15 ++-- crates/ui-egui/src/widgets/style_picker.rs | 2 +- docs/gaps.md | 3 +- docs/ui-parity.md | 4 +- 16 files changed, 112 insertions(+), 47 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index cf7c7cb6..c1a3af30 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-29 · **Change:** minor (`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-10-29 · **Change:** minor (colour picker outside-click dismissal and sidebar anchoring; `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 @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Colour pickers dismiss on presses outside their card, including the sidebar, and open 8 pt beside the sidebar aligned to their control (lifted to fit the screen); switching targets resets a dragged position and commits pending edits. Eyedropper sampling stays open. UI regression tests cover added fills/strokes and page colours; headline estimates unchanged. | | 2026-10-29 | minor | Which copy is which (M8, #995): `edit.duplicate` with `map: true` also returns `map`, each original layer's id, descendants included (nested frames, groups, instances and their sublayers), paired with its copy's id, so a batch can edit inside a copy through a step reference (`"field": "map/6"`) instead of inspecting it. Without `map` the result is unchanged. Regression tests in `crates/engine/src/duplicate_map_tests.rs`. Numbers unchanged | | 2026-10-29 | minor | Bounded replay log (#1069, from a security audit): the batches a session keeps for apps that reconnect were bounded only by count (10,000 batches, 1,000,000 changes) on a desktop host and a private `figcraft-server`, so a long busy session could hold any number of bytes. `Limits::max_log_bytes` now defaults to 64 MiB, and `figcraft-server` takes it from a new `max_log_mb` setting (default 64; open to the public, also at most a quarter of `max_file_mb`, as before). An app further behind than the trimmed log catches up with the whole file, as it already did past the count limits. Nine 8 MiB renames with default limits kept all 75.5 MB before; now 58.7 MB, and a client that missed them gets a snapshot. The protocol is unchanged. | | 2026-10-29 | minor | M8, #998: MCP tools that act on a document (`run_command`, `batch`, `create_node`, `set_properties`, `inspect_document`, `get_node`, `select`, `export`, `screenshot`, `save_file`, `undo`, `redo`) take an optional `document`, an open file's `id` (`doc.list` and the `document.inspect` outline now carry it) or path, and so do the control methods `engine.execute`, `document.inspect`, `node.get` and `ui.render`: the call acts on that file and the tab in front stays in front, so an attached agent keeps editing its own file when the user switches tabs. Calls without it act on the file in front as before; other control methods refuse a `document` behind the tab in front. Numbers unchanged | diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index 7a27aff3..eff4cfde 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -34,14 +34,21 @@ impl FigApp { /// Clicking the control that opened the colour picker closes it again; clicking another opens /// the picker for that one. - pub fn toggle_picker(&mut self, target: crate::colorpicker::PickerTarget, _top: Option) { + pub fn toggle_picker(&mut self, target: crate::colorpicker::PickerTarget, button: Option) { if self.ui.picker.as_ref() == Some(&target) { crate::colorpicker::close(self); } else { - self.ui.picker = Some(target); + self.open_picker(target, button); } } + /// Start a fresh picker beside its control, committing edits to the previous target first. + pub fn open_picker(&mut self, target: crate::colorpicker::PickerTarget, button: Option) { + crate::colorpicker::close(self); + self.ui.picker = Some(target); + self.ui.picker_button = button; + } + /// Leaving Design ends a pen path as Escape does and goes back to Move: Prototype and Inspect /// don't draw (#307). Checked as the frame starts (the control channel) and after the panels /// and shortcuts (the mode switcher, the tabs, Shift+E/Shift+D), before the canvas sees more. diff --git a/crates/ui-egui/src/colorpicker_selection_tests.rs b/crates/ui-egui/src/colorpicker_selection_tests.rs index 85c8d291..a115118c 100644 --- a/crates/ui-egui/src/colorpicker_selection_tests.rs +++ b/crates/ui-egui/src/colorpicker_selection_tests.rs @@ -58,16 +58,16 @@ fn clicking_the_swatch_again_closes_the_picker() { let (mut u, a, _) = two(); u.app.run("fill.add", json!({"ids": [a], "color": "#00FF00"})); let target = PickerTarget::Paint { fills: true, index: 0, ids: vec![a] }; - u.app.toggle_picker(target.clone(), Some(200.0)); + u.app.toggle_picker(target.clone(), Some(egui::Rect::from_min_size(egui::pos2(1250.0, 200.0), egui::vec2(16.0, 16.0)))); u.frame(vec![]); u.frame(vec![]); assert_eq!(u.app.ui.picker, Some(target.clone()), "the swatch opens the picker"); - u.app.toggle_picker(target.clone(), Some(200.0)); + u.app.toggle_picker(target.clone(), Some(egui::Rect::from_min_size(egui::pos2(1250.0, 200.0), egui::vec2(16.0, 16.0)))); u.frame(vec![]); assert!(u.app.ui.picker.is_none(), "the same swatch toggles it shut"); // Another paint of the same layer opens the picker for that one. let second = PickerTarget::Paint { fills: true, index: 1, ids: vec![a] }; - u.app.toggle_picker(second.clone(), Some(200.0)); + u.app.toggle_picker(second.clone(), Some(egui::Rect::from_min_size(egui::pos2(1250.0, 200.0), egui::vec2(16.0, 16.0)))); u.frame(vec![]); assert_eq!(u.app.ui.picker, Some(second), "another paint opens the picker for it"); } diff --git a/crates/ui-egui/src/dialogs/plugins_ui.rs b/crates/ui-egui/src/dialogs/plugins_ui.rs index 339573ff..57491cea 100644 --- a/crates/ui-egui/src/dialogs/plugins_ui.rs +++ b/crates/ui-egui/src/dialogs/plugins_ui.rs @@ -252,7 +252,7 @@ fn panel_windows(app: &mut FigApp, ctx: &Context) { app.run("plugin.close", json!({"id": id})); } Some(Used::Set(control, value)) => run(app, "plugin.event", json!({"id": id, "control": control, "value": value})), - Some(Used::Pick(control)) => app.ui.picker = Some(PickerTarget::PluginField { plugin: id, control, pending: None }), + Some(Used::Pick(control)) => app.open_picker(PickerTarget::PluginField { plugin: id, control, pending: None }, None), } } } @@ -590,7 +590,7 @@ fn form_window(app: &mut FigApp, ctx: &Context) { }); app.ui.plugin_form = (open && !submit && !cancel).then_some(form.clone()); if let Some(control) = pick { - app.ui.picker = Some(PickerTarget::PluginField { plugin: form.id.clone(), control, pending: None }); + app.open_picker(PickerTarget::PluginField { plugin: form.id.clone(), control, pending: None }, None); } if submit { run(app, "plugin.run", json!({"id": form.id, "command": form.command, "params": form.values})); diff --git a/crates/ui-egui/src/dialogs/variables_panel.rs b/crates/ui-egui/src/dialogs/variables_panel.rs index 9939cfc9..93d567a8 100644 --- a/crates/ui-egui/src/dialogs/variables_panel.rs +++ b/crates/ui-egui/src/dialogs/variables_panel.rs @@ -840,7 +840,7 @@ fn value_cell(app: &mut FigApp, ui: &mut Ui, view: &View, v: &Variable, coll: &C let sw = Rect::from_center_size(pos2(body.min.x + 13.0, body.center().y), vec2(14.0, 14.0)); widgets::paint_swatch(ui.painter(), sw, &figcraft_color::Paint::solid(*c)); if ui.interact(sw.expand(3.0), id.with("swatch"), Sense::click()).tip(tr("Edit colour")).clicked() { - app.ui.picker = Some(PickerTarget::Variable { id: v.id.clone(), mode: mode.to_string() }); + app.toggle_picker(PickerTarget::Variable { id: v.id.clone(), mode: mode.to_string() }, Some(sw.expand(3.0))); } let hex_r = Rect::from_min_max(pos2(sw.max.x + 8.0, body.min.y + 4.0), pos2(body.max.x - 44.0, body.max.y - 4.0)); if let Some(h) = widgets::text_field_inline(ui, id.with("hex"), &c.to_hex(), hex_r, regular(11.0)) diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index 2d8f6287..9c51a292 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -476,14 +476,18 @@ fn scrubbing_colour_picker_fields_is_live_and_one_undo_step() { let [r0, .., a0] = rgba(&u); u.ctx.data_mut(|d| d.insert_temp(Id::unique("cp_model"), 1usize)); u.app.ui.picker = Some(crate::colorpicker::PickerTarget::Paint { fills: true, index: 0, ids: vec![a] }); - // The picker window takes a frame to size itself. - for _ in 0..3 { + // Settle sizing and the anchored position before reading field hit rectangles. + for _ in 0..5 { u.frame(vec![]); } let steps = undo_steps(&u); let from = lead(&u, Id::unique(("cp_f", "r"))); drag(&mut u, from, -20.0, Modifiers::NONE); assert_eq!((rgba(&u)[0], undo_steps(&u)), (r0 - 20, steps + 1), "R"); + // Committing adds a recent colour row and can lift the anchored picker. Settle its layout. + for _ in 0..3 { + u.frame(vec![]); + } // The alpha field: a scrub moves it too. let from = lead(&u, Id::unique("cp_a")); drag(&mut u, from, -30.0, Modifiers::NONE); @@ -504,7 +508,7 @@ fn lch_and_oklch_picker_fields_edit_lightness_keeping_hue() { assert_eq!(before.to_hex(), "0D99FF"); u.ctx.data_mut(|d| d.insert_temp(Id::unique("cp_model"), model)); u.app.ui.picker = Some(crate::colorpicker::PickerTarget::Paint { fills: true, index: 0, ids: vec![a] }); - for _ in 0..3 { + for _ in 0..5 { u.frame(vec![]); } let steps = undo_steps(&u); @@ -524,14 +528,16 @@ fn type_into_picker(u: &mut Ui, id: &str, text: &str) { click(u, rect.center()); u.frame(vec![Event::Text(text.into())]); key(u, Key::Enter, Modifiers::NONE); - u.frame(vec![]); + for _ in 0..3 { + u.frame(vec![]); + } } /// A picker on layer `a` in model `model` (an index into the model menu), drawn and settled. fn open_picker(u: &mut Ui, a: NodeId, model: usize) { u.ctx.data_mut(|d| d.insert_temp(Id::unique("cp_model"), model)); u.app.ui.picker = Some(crate::colorpicker::PickerTarget::Paint { fills: true, index: 0, ids: vec![a] }); - for _ in 0..3 { + for _ in 0..5 { u.frame(vec![]); } } diff --git a/crates/ui-egui/src/panel_remove_tests.rs b/crates/ui-egui/src/panel_remove_tests.rs index 50de5bf9..98ddc4d1 100644 --- a/crates/ui-egui/src/panel_remove_tests.rs +++ b/crates/ui-egui/src/panel_remove_tests.rs @@ -1,6 +1,6 @@ //! Adding and removing fill, effect and layout grid rows in the Design panel, driven through egui headless: -//! adding a paint opens its colour picker; an open colour picker and open settings stay with their items, -//! and go when their item goes. +//! adding a paint opens its colour picker beside the sidebar; outside presses dismiss it, while +//! open effect and grid settings stay with their items and go when their item goes. use egui::{Event, Id, Pos2}; use figcraft_doc::NodeId; @@ -70,10 +70,9 @@ fn flag(u: &Ui, salt: &'static str, i: usize) -> bool { u.ctx.data(|d| d.get_temp::(Id::unique((salt, i)))).unwrap_or(false) } -/// Asserts removing a fill keeps the open picker on the surviving paint (with its index -/// shifted), and removing the paint being edited closes the picker. +/// Removing a fill acts on the paint and dismisses the picker on the outside press. #[test] -fn removing_a_fill_keeps_the_picker_on_its_paint() { +fn removing_a_fill_dismisses_the_picker() { let mut s = Session::new(); let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()); s.execute("fill.set", &json!({"ids": [r], "color": "#FF0000"})).unwrap(); @@ -82,7 +81,7 @@ fn removing_a_fill_keeps_the_picker_on_its_paint() { s.execute("select.set", &json!({"ids": [r]})).unwrap(); let mut u = Ui::new(s); u.ctx.enable_accesskit(); - // Green's swatch, then red's Remove: the picker follows green to index 0. + // Green's swatch, then red's Remove: the outside press dismisses the picker. let sw = u.ctx.read_response(Id::unique(("swatch", true, 1usize))).unwrap().rect.center(); click(&mut u, sw); assert_eq!(u.app.ui.picker, Some(PickerTarget::Paint { fills: true, index: 1, ids: vec![r] })); @@ -90,15 +89,15 @@ fn removing_a_fill_keeps_the_picker_on_its_paint() { click(&mut u, rm); let fills: Vec = u.app.session.doc().unwrap().node(r).unwrap().fills.iter().filter_map(|p| p.solid_color()).map(|c| c.to_hex()).collect(); assert_eq!(fills, ["00FF00", "0000FF"]); - assert_eq!(u.app.ui.picker, Some(PickerTarget::Paint { fills: true, index: 0, ids: vec![r] })); - // Removing the paint being edited closes the picker. + assert_eq!(u.app.ui.picker, None); + // Removing the surviving paint works with the picker dismissed. let rm = remove_button(&mut u, "fills", 0); click(&mut u, rm); assert_eq!(u.app.ui.picker, None); } -/// Asserts removing effects or grids moves open settings and an open colour picker to the -/// surviving row, or closes them when their own row is removed. +/// Removing effects or grids moves open settings to the surviving row; the outside press +/// dismisses the colour picker. Removing the open item closes its settings. #[test] fn removing_effects_and_grids_keeps_open_settings_with_their_rows() { let mut s = Session::new(); @@ -118,7 +117,7 @@ fn removing_effects_and_grids_keeps_open_settings_with_their_rows() { click(&mut u, rm); assert_eq!(u.app.session.doc().unwrap().node(f).unwrap().effects.len(), 2); assert!(flag(&u, "fx_open", 1) && !flag(&u, "fx_open", 2)); - assert_eq!(u.app.ui.picker, Some(PickerTarget::Effect { ids: vec![f], index: 1, secondary: false })); + assert_eq!(u.app.ui.picker, None); // Removing the open effect closes its settings and the picker. let rm = remove_button(&mut u, "effects", 1); click(&mut u, rm); @@ -225,3 +224,51 @@ fn the_swatch_that_opened_the_colour_picker_closes_it_again() { click(&mut u, sw); assert_eq!(u.app.ui.picker, None, "the same swatch closes it again"); } + +/// Blank space in the sidebar is outside the picker too, including after adding a paint. +#[test] +fn added_paints_open_beside_the_sidebar_and_close_on_a_sidebar_press() { + for (label, fills) in [("Add fill", true), ("Add stroke", false)] { + let mut s = Session::new(); + let r = id(s.execute("shape.rectangle", &json!({"width": 50, "height": 50})).unwrap()); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let mut u = Ui::new(s); + u.ctx.enable_accesskit(); + let add = button(&mut u, label); + click(&mut u, add); + for _ in 0..3 { + u.frame(vec![]); + } + assert!(matches!(u.app.ui.picker, Some(PickerTarget::Paint { fills: f, .. }) if f == fills)); + let pop = u.app.ui.picker_popover.as_ref().unwrap(); + let sidebar_left = 1440.0 - u.app.ui.right_width; + assert!((pop.rect.max.x - (sidebar_left - 8.0)).abs() < 1.0, "{:?}", pop.rect); + assert!(pop.rect.min.y <= add.y, "the picker aligns with its opener or lifts to fit"); + assert!(pop.rect.max.y <= 900.0 - 8.0); + u.button(Pos2::new(1420.0, 850.0), true); + assert!(u.app.ui.picker.is_none(), "a sidebar press dismisses the picker before release"); + } +} + +#[test] +fn page_colour_picker_closes_on_an_outside_press_and_reopens_at_its_control() { + let mut u = Ui::new(Session::new()); + let button = egui::Rect::from_min_size(Pos2::new(1230.0, 220.0), egui::vec2(16.0, 16.0)); + u.app.toggle_picker(PickerTarget::PageBackground, Some(button)); + for _ in 0..3 { + u.frame(vec![]); + } + let old = u.app.ui.picker_popover.take().unwrap().at(Pos2::new(300.0, 100.0)); + u.app.ui.picker_popover = Some(old); + u.frame(vec![]); + u.button(Pos2::new(1420.0, 850.0), true); + assert!(u.app.ui.picker.is_none()); + u.button(Pos2::new(1420.0, 850.0), false); + u.app.toggle_picker(PickerTarget::PageBackground, Some(button)); + for _ in 0..3 { + u.frame(vec![]); + } + let rect = u.app.ui.picker_popover.as_ref().unwrap().rect; + assert!((rect.max.x - (1440.0 - u.app.ui.right_width - 8.0)).abs() < 1.0); + assert!((rect.min.y - button.min.y).abs() < 1.0); +} diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index e2439d40..f5ddeffd 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -317,7 +317,7 @@ fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) { let sw = widgets::swatch(ui, &p, 16.0); if sw.clicked() { open_picker = true; - swatch_top = Some(sw.rect.min.y); + swatch_top = Some(sw.rect); } if let Some(h) = widgets::text_field(ui, Id::unique("pagebg"), &bg.to_hex(), 96.0, "") { new_hex = Some(h); diff --git a/crates/ui-egui/src/panels/right/effects.rs b/crates/ui-egui/src/panels/right/effects.rs index 2d458de9..578bd164 100644 --- a/crates/ui-egui/src/panels/right/effects.rs +++ b/crates/ui-egui/src/panels/right/effects.rs @@ -335,7 +335,7 @@ fn fx_color(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Effect, let Some(c) = e.color(secondary) else { return }; let sw = widgets::swatch(ui, &Paint::solid(c), 16.0); if sw.clicked() { - app.toggle_picker(crate::colorpicker::PickerTarget::Effect { ids: ids.to_vec(), index: i, secondary }, Some(sw.rect.min.y)); + app.toggle_picker(crate::colorpicker::PickerTarget::Effect { ids: ids.to_vec(), index: i, secondary }, Some(sw.rect)); } if let Some(h) = widgets::text_field(ui, Id::unique(("fx_c", i, secondary)), &c.to_hex(), 80.0, "") && let Some(nc) = figcraft_color::Color::from_hex_or_name(&h) diff --git a/crates/ui-egui/src/panels/right/paints.rs b/crates/ui-egui/src/panels/right/paints.rs index a24fca55..ce99ceb7 100644 --- a/crates/ui-egui/src/panels/right/paints.rs +++ b/crates/ui-egui/src/panels/right/paints.rs @@ -255,7 +255,7 @@ fn range_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId, start: widgets::paint_swatch(ui.painter(), sw, p); let range_swatch = ui.interact(sw.expand(2.0), Id::unique("range_swatch"), Sense::click()); if range_swatch.clicked() { - app.toggle_picker(crate::colorpicker::PickerTarget::TextRange { id, start, end }, Some(range_swatch.rect.min.y)); + app.toggle_picker(crate::colorpicker::PickerTarget::TextRange { id, start, end }, Some(range_swatch.rect)); } if let Some(c) = p.solid_color() { let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_r)); @@ -272,7 +272,7 @@ fn range_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId, start: None => { ui.painter().text(pos2(text_r.min.x, r.center().y), Align2::LEFT_CENTER, tr("Mixed"), regular(11.0), t.text_secondary); if ui.interact(r, Id::unique("range_mixed"), Sense::click()).clicked() { - app.ui.picker = Some(crate::colorpicker::PickerTarget::TextRange { id, start, end }); + app.open_picker(crate::colorpicker::PickerTarget::TextRange { id, start, end }, Some(r)); } } } @@ -314,7 +314,7 @@ pub(super) fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeI if app.run(if fills { "fill.add" } else { "stroke.add" }, json!({"ids": ids})).is_some() && let Some(l) = &list { - app.ui.picker = Some(crate::colorpicker::PickerTarget::Paint { fills, index: l.len(), ids: ids.clone() }); + app.open_picker(crate::colorpicker::PickerTarget::Paint { fills, index: l.len(), ids: ids.clone() }, Some(header.rect)); } } else { style_picker::open(app, ui, &ids, slot); @@ -464,7 +464,7 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint, app.ui.picker_button = Some(sresp.rect); } if sresp.clicked() { - app.toggle_picker(crate::colorpicker::PickerTarget::Paint { fills, index: i, ids: ids.clone() }, Some(sresp.rect.min.y)); + app.toggle_picker(crate::colorpicker::PickerTarget::Paint { fills, index: i, ids: ids.clone() }, Some(sresp.rect)); } let text_r = Rect::from_min_max(pos2(sw.max.x + 8.0, r.min.y), pos2(r.max.x - 52.0, r.max.y)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_r)); diff --git a/crates/ui-egui/src/panels/selection_colors.rs b/crates/ui-egui/src/panels/selection_colors.rs index 3fdada05..384db7c0 100644 --- a/crates/ui-egui/src/panels/selection_colors.rs +++ b/crates/ui-egui/src/panels/selection_colors.rs @@ -47,7 +47,7 @@ fn colors(ui: &Ui, document: u64, doc: &Arc, ids: &[NodeId], interacti colors } -pub(crate) fn toggle(app: &mut FigApp, targets: Vec, top: Option) { +pub(crate) fn toggle(app: &mut FigApp, targets: Vec, top: Option) { let Ok(ds) = app.session.ds() else { return }; app.toggle_picker(PickerTarget::SelectionColor { targets, selection: ds.selection.clone(), document: ds.uid }, top); } @@ -72,7 +72,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, ids: &[NodeId crate::right::row(ui, 32.0, |ui| { let swatch = widgets::swatch(ui, &group.paint, 24.0).tip(tr("Edit colour")); if swatch.clicked() { - toggle(app, group.targets.clone(), Some(swatch.rect.min.y)); + toggle(app, group.targets.clone(), Some(swatch.rect)); } let width = (ui.available_width() - 116.0).max(40.0); let linked_name = group @@ -100,7 +100,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, ids: &[NodeId } else { let response = widgets::dropdown(ui, &widgets::paint_name(&group.paint), width, None); if response.clicked() { - toggle(app, group.targets.clone(), Some(response.rect.min.y)); + toggle(app, group.targets.clone(), Some(response.rect)); } } let alpha = format!("{}%", figcraft_doc::trim_num(f64::from(group.paint.opacity) * 100.0)); diff --git a/crates/ui-egui/src/popover.rs b/crates/ui-egui/src/popover.rs index dbcf3bdb..14d1e133 100644 --- a/crates/ui-egui/src/popover.rs +++ b/crates/ui-egui/src/popover.rs @@ -140,8 +140,7 @@ impl Popover { self } - /// Whether a press outside it closes it (the default). The colour picker stays open, so a - /// press on a row beside it keeps editing the paint it is on. + /// Whether a press outside it closes it (the default). Disable while sampling with an eyedropper. pub fn close_on_outside(mut self, yes: bool) -> Self { self.outside = yes; self @@ -231,6 +230,9 @@ impl Popover { .into(), ]; ui.painter().set(bg, egui::Shape::Vec(shapes)); + if self.rect != panel { + ctx.request_repaint(); + } self.rect = panel; self.header = header; self.slot = slot; diff --git a/crates/ui-egui/src/widgets/colorpicker.rs b/crates/ui-egui/src/widgets/colorpicker.rs index c3bd47c5..f8b43d74 100644 --- a/crates/ui-egui/src/widgets/colorpicker.rs +++ b/crates/ui-egui/src/widgets/colorpicker.rs @@ -864,7 +864,10 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { PickerTarget::PluginField { plugin, .. } => crate::plugins_ui::field_window(app, ctx, plugin).map(|r| pos2(r.min.x - 248.0, r.min.y)), _ => None, }; - let pos = beside.unwrap_or(pos2(screen.max.x - app.ui.right_width - 260.0, 140.0)); + let open = beside.map(Open::At).unwrap_or_else(|| { + let top = app.ui.picker_button.map_or(140.0, |r| r.min.y); + Open::Anchor(pos2(screen.max.x - app.ui.right_width - 8.0, top)) + }); // The colour picker's own header — title 16 in, the X 8 in from the right — is the shared // popover's chrome now; the picker keeps its body. Its area id is [`window_id`], the one the // style picker looks up to tell a press in here from one outside. @@ -873,13 +876,9 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { // colour it just sampled — is the press, not a press outside: neither may close the picker. let picking = app.session.tool.pick_only || app.session.tool.picked.is_some(); pop = pop.close_on_outside(!picking); - // Closing is the shared widget's: the X, Escape and a press outside it. Presses in the panel - // beside it keep it (swatches and fields there switch the colour it edits); the control that - // opened it is held too, so pressing it again reaches its own toggle. - let panel = Rect::from_min_max(pos2(screen.max.x - app.ui.right_width, screen.min.y), screen.max); - let mut held: Vec = vec![panel]; - held.extend(app.ui.picker_button); - let closed = pop.show(ctx, Open::At(pos), Hold { rects: &held, escape: false }, |ui| { + // Only the opening control is held out: any other sidebar or canvas press dismisses it. + let held: Vec = app.ui.picker_button.into_iter().collect(); + let closed = pop.show(ctx, open, Hold { rects: &held, escape: false }, |ui| { ui.spacing_mut().item_spacing = vec2(8.0, 8.0); // The eyedropper's colour goes to this picker's paint (a stroke, an effect, a gradient stop...). if let Some(c) = app.session.tool.picked.take() { diff --git a/crates/ui-egui/src/widgets/style_picker.rs b/crates/ui-egui/src/widgets/style_picker.rs index b1001a0e..11708388 100644 --- a/crates/ui-egui/src/widgets/style_picker.rs +++ b/crates/ui-egui/src/widgets/style_picker.rs @@ -1149,7 +1149,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { if let Some(e) = &p.edit { commit_edit(app, &doc, e); } - app.ui.picker = Some(PickerTarget::Style { id, index }); + app.open_picker(PickerTarget::Style { id, index }, None); } Some(Act::Unbind(id, field)) => { app.run("style.bind", json!({"style": id, "field": field})); diff --git a/docs/gaps.md b/docs/gaps.md index cac8392f..50c8a050 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (`.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); Arabic draft catalog; gap 7: the browser refines a zoom's preview once the zoom has paused; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (colour picker outside-click dismissal and sidebar anchoring; `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); Arabic draft catalog; gap 7: the browser refines a zoom's preview once the zoom has paused; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -555,6 +555,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Colour pickers dismiss on presses outside their card, including the sidebar, and open 8 pt beside the sidebar aligned to their control (lifted to fit the screen); switching targets resets a dragged position and commits pending edits. Eyedropper sampling stays open. UI regression tests cover added fills/strokes and page colours; headline estimates unchanged. | | 2026-10-28 | minor | Arabic draft: 1,857 UI strings, 620 command labels, 31 menu wordings; count-neutral plurals and web font gate. Native review and panel mirroring remain open; grade and estimates unchanged. Evidence: `locales/ar.toml`, i18n catalog tests and [translation notes](translating.md#arabic-draft) | | 2026-10-28 | minor | Gap 7: the browser refines a zoom's preview only once the zoom has paused for 120 ms, so a wheel zoom no longer runs a blocking full render between every two steps. Numbers unchanged | | 2026-10-27 | minor | M12: the MCP agent feedback workflow landed (comments as tasks, `comment.prompt`, Copy for AI, `fix_comments` / `watch_comments`, `figcraft://comments`, a bounded event log, `events.poll` and `wait_for_changes`). Figma AI features and local models are still not started, so gap 19's missing list and estimate stand | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 1503b8e5..6f86a84f 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-10-29 · **Change:** minor (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; 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; 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; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; 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) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (colour picker outside-click dismissal and sidebar anchoring; 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; 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; 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; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; 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) · **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: @@ -30,6 +30,7 @@ interaction feel not yet compared. | Colour names in hex fields | done | every hex field (fills, strokes, text ranges, effects, layout grids, colour variables, page and prototype backgrounds, the colour picker's Hex) takes a CSS colour name as well: `red` becomes `FF0000`; case, spaces and hyphens are ignored (`Light Blue`); names are opaque and keep the colour's opacity; hex wins where both could match. The picker's CSS field takes every CSS name too (`Color::from_hex_or_name`, `color::named`) | | Fields select their text on focus | done | a click or Tab into a one-line field selects all of its text, so typing replaces the value: hex and colour fields, number fields, inline fields (a fill's hex, variable values, renames), names, search boxes and dialog fields; a drag from the press selects only what it covers; multi-line fields (descriptions, comments) keep the caret where clicked. The first click into an inline field (a fill's hex) now takes the keyboard; it took a second click. A rename (double-click, F2, File ▸ Rename, a page) selects the whole name before its field first draws, so a key typed in that same frame replaces the name; it was appended (`widgets::select_all_on_focus`, `select_all_tests`, `layers_rename_tests`) | | Colour picker models | done | Hex, RGB, HSL, HSB, LCH, OKLCH and CSS; LCH and OKLCH keep lightness and hue when a colour leaves sRGB (chroma comes down); the values typed stay while the colour is theirs (a hue typed on a grey, a chroma sRGB can't hold); CSS reads `lch()` and `oklch()` with any hue unit and `none` (`figcraft_color::{Lch, Oklch}`). Values are the stored channels read as sRGB | +| Colour picker placement and dismissal | done | Opens 8 pt beside the sidebar at the opening control, lifted to fit the screen; outside presses (including the sidebar), Escape and X close it. Reopening resets a dragged position; eyedropper sampling keeps it open. | | Colour picker on a new fill or stroke | done | + opens the picker on the new paint when the selection shares one paint list; on a mixed selection + adds a paint to each layer and no picker opens (#404) | | Blend mode menus: the layer's (Design panel) and each paint's (colour picker) | done | Pass through and Normal are distinct (Normal isolates a group's or frame's children); paints have no pass-through; plus darker drawn exactly on the canvas | @@ -174,6 +175,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Colour pickers dismiss on presses outside their card, including the sidebar, and open 8 pt beside the sidebar aligned to their control (lifted to fit the screen); switching targets resets a dragged position and commits pending edits. Eyedropper sampling stays open. UI regression tests cover added fills/strokes and page colours; headline estimates unchanged. | | 2026-10-29 | minor | Crop image (#1464): Object ▸ Crop image, the right-click menu and a crop button on the image fill row enter crop mode on the canvas (`image.crop`; the image dimmed outside the layer, corner brackets and edge bars on its box; Enter, Escape or a click outside finishes). The row stays done | | 2026-10-29 | minor | Toolbar (#1210): every state of the pill (tools, vector edit, shape builder, view only) is laid out on one 32 pt cell, 8 pt in from its ends and 8 pt between its items, with a 1 pt divider and 14 pt mode-switcher marks; the Resources button is gone, as in UI3 (the left rail's Assets tab, Shift+I, Alt+2 and View ▸ Panels ▸ Assets open the Assets panel); a narrow window still scrolls the pill (#1209) (`resources_tests`, `toolbar` tests). Numbers unchanged | | 2026-10-27 | minor | The Design panel's sections sit on one grid (#858): 16 pt left and 8 pt right margins, two 88 pt columns with 8 pt gutters and a 24 pt button column, 24 pt rows, 11 pt semibold section titles over 10 pt captions, 4 pt between the two buttons at a row's end; the panel's frame no longer carries a stroke (it counts as margin), so an icon's box ends 8 pt from the window edge. Checked in the app on Windows over the control channel | From 581549617ebccab70a2d96872191b6e5e0628926 Mon Sep 17 00:00:00 2001 From: Carbocode Date: Thu, 29 Oct 2026 14:42:59 +0100 Subject: [PATCH 29/63] M1: sync Carbocode contributor name from GitHub --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 14c2ad43..77553d44 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -95,3 +95,6 @@ sync_github_name = true [people.StrajnarFilip] display_name = "Filip Strajnar" + +[people.Carbocode] +sync_github_name = true From 0876f6dbc9ca5ea3c01165c1d0c6055da2651266 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:51:04 +0100 Subject: [PATCH 30/63] Reject non-boolean values for node.set flag properties Found while testing crates/engine/src/cmd/props.rs:614 (also :708, :717, :718, :826 and :1007) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/props.rs | 14 ++++++++------ crates/engine/src/node_find_tests.rs | 24 ++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index ee5ba4f6..d89b25ae 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -611,7 +611,7 @@ fn apply_corners(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) "cornerSmoothing" => doc.get_mut(id)?.corners.smoothing = f64_of(v).unwrap_or(0.0).clamp(0.0, 1.0), "effects" => doc.get_mut(id)?.effects = effects_from(cmd, v)?, "clip" | "clipsContent" => doc.get_mut(id)?.clip = v.as_bool().ok_or_else(|| bad(cmd, "`clip` must be a boolean"))?, - "isMask" => doc.get_mut(id)?.is_mask = v.as_bool().unwrap_or(false), + "isMask" => doc.get_mut(id)?.is_mask = v.as_bool().ok_or_else(|| bad(cmd, format!("`{k}` must be a boolean")))?, "devStatus" => { let st = if v.is_null() { Some(DevStatus::None) @@ -705,7 +705,7 @@ fn apply_layout(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) - "contentAlign" | "counterAxisAlignContent" => { layout_mut(cmd, k, doc.get_mut(id)?)?.content_align = enum_of(v).ok_or_else(|| bad(cmd, "contentAlign: auto|spaceBetween"))? } - "wrap" => layout_mut(cmd, k, doc.get_mut(id)?)?.wrap = v.as_bool().unwrap_or(false), + "wrap" => layout_mut(cmd, k, doc.get_mut(id)?)?.wrap = v.as_bool().ok_or_else(|| bad(cmd, format!("`{k}` must be a boolean")))?, "gridColumns" | "gridRows" => { let count = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.clamp(1.0, 1000.0) as u32; let l = layout_mut(cmd, k, doc.get_mut(id)?)?; @@ -714,8 +714,10 @@ fn apply_layout(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) - *n = count; tracks.truncate(count as usize); } - "strokesIncluded" => layout_mut(cmd, k, doc.get_mut(id)?)?.strokes_included = v.as_bool().unwrap_or(false), - "reverseZ" => layout_mut(cmd, k, doc.get_mut(id)?)?.reverse_z = v.as_bool().unwrap_or(false), + "strokesIncluded" => { + layout_mut(cmd, k, doc.get_mut(id)?)?.strokes_included = v.as_bool().ok_or_else(|| bad(cmd, format!("`{k}` must be a boolean")))? + } + "reverseZ" => layout_mut(cmd, k, doc.get_mut(id)?)?.reverse_z = v.as_bool().ok_or_else(|| bad(cmd, format!("`{k}` must be a boolean")))?, _ => return Ok(false), } Ok(true) @@ -823,7 +825,7 @@ fn apply_meta(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) -> "interactions" => doc.get_mut(id)?.interactions = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("interactions: {e}")))?, "overflow" => doc.get_mut(id)?.overflow = enum_of(v).ok_or_else(|| bad(cmd, "overflow: none|horizontal|vertical|both"))?, "scrollPosition" => doc.get_mut(id)?.scroll_position = enum_of(v).ok_or_else(|| bad(cmd, "scrollPosition: scrolls|fixed|sticky"))?, - "constrainProportions" => doc.get_mut(id)?.constrain_proportions = v.as_bool().unwrap_or(false), + "constrainProportions" => doc.get_mut(id)?.constrain_proportions = v.as_bool().ok_or_else(|| bad(cmd, format!("`{k}` must be a boolean")))?, _ => return Ok(false), } Ok(true) @@ -1004,7 +1006,7 @@ fn apply_shape(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) -> "operandOperation" => set_operand_op(doc, id, v, cmd)?, "sectionContentsHidden" => { if let NodeKind::Section { contents_hidden } = &mut doc.get_mut(id)?.kind { - *contents_hidden = v.as_bool().unwrap_or(false); + *contents_hidden = v.as_bool().ok_or_else(|| bad(cmd, "`sectionContentsHidden` must be a boolean"))?; } } "background" => { diff --git a/crates/engine/src/node_find_tests.rs b/crates/engine/src/node_find_tests.rs index d8729357..a60383be 100644 --- a/crates/engine/src/node_find_tests.rs +++ b/crates/engine/src/node_find_tests.rs @@ -194,3 +194,27 @@ fn node_find_leaves_document_selection_page_and_history_untouched() { assert_ne!(s.doc().unwrap().get(layer).unwrap().name, "Previous edit"); assert_eq!(s.ds().unwrap().history.undo.len(), undo - 1); } + +#[test] +fn node_set_rejects_non_boolean_flags() { + let mut s = Session::new(); + let page = s.page().unwrap(); + let rect = add(&mut s, page, NodeKind::Rectangle, "R"); + let frame = add(&mut s, page, NodeKind::Frame, "F"); + ex(&mut s, "node.set", json!({"ids": [rect], "props": {"isMask": true, "constrainProportions": true}})); + ex(&mut s, "node.set", json!({"ids": [frame], "props": {"layoutMode": "HORIZONTAL", "wrap": true, "strokesIncluded": true, "reverseZ": true}})); + for (id, key) in [(rect, "isMask"), (rect, "constrainProportions"), (frame, "wrap"), (frame, "strokesIncluded"), (frame, "reverseZ")] { + let r = s.execute("node.set", &json!({"ids": [id], "props": {key: "yes"}})); + assert!(r.is_err(), "{key} accepted a non-boolean"); + } + let sec = add(&mut s, page, NodeKind::Section { contents_hidden: false }, "S"); + ex(&mut s, "node.set", json!({"ids": [sec], "props": {"sectionContentsHidden": true}})); + let r = s.execute("node.set", &json!({"ids": [sec], "props": {"sectionContentsHidden": "yes"}})); + assert!(r.is_err(), "sectionContentsHidden accepted a non-boolean"); + let doc = s.doc().unwrap(); + assert!(matches!(doc.get(sec).unwrap().kind, figcraft_doc::NodeKind::Section { contents_hidden: true })); + assert!(doc.get(rect).unwrap().is_mask && doc.get(rect).unwrap().constrain_proportions); + let l = doc.get(frame).unwrap(); + let l = l.layout.as_ref().unwrap(); + assert!(l.wrap && l.strokes_included && l.reverse_z); +} From ec96cae46aef61cfe23ba4e14c9170cec2a71352 Mon Sep 17 00:00:00 2001 From: Carbocode Date: Thu, 29 Oct 2026 14:57:08 +0100 Subject: [PATCH 31/63] M2: add auto layout from groups and convert them in place --- ROADMAP.md | 3 +- crates/engine/src/cmd/structure.rs | 11 ++++- crates/engine/src/convert_container_tests.rs | 50 ++++++++++++++++++++ crates/ui-egui/src/panels/layout_panel.rs | 3 +- docs/auto-layout-parity.md | 19 +++++++- 5 files changed, 80 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index cf7c7cb6..e9c2e032 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-29 · **Change:** minor (`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-10-29 · **Change:** minor (Layout sidebar: groups expose Add auto layout and convert to frames in place; `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 @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M2: the Design sidebar’s Layout header shows Add auto layout (+) for groups as well as frames. `layout.add` converts a single selected group into a frame in place, retaining its id, name and direct children, so auto layout arranges those children instead of treating the whole group as one fixed item inside a new wrapper. This matches the Figma workflow reported by the contributor and the sidebar workflow familiar to users of similar design tools. Sidebar, context menu, shortcut and programmatic callers share the fix; conversion and layout undo together. Regression tests in `convert_container_tests`. Numbers unchanged | | 2026-10-29 | minor | Which copy is which (M8, #995): `edit.duplicate` with `map: true` also returns `map`, each original layer's id, descendants included (nested frames, groups, instances and their sublayers), paired with its copy's id, so a batch can edit inside a copy through a step reference (`"field": "map/6"`) instead of inspecting it. Without `map` the result is unchanged. Regression tests in `crates/engine/src/duplicate_map_tests.rs`. Numbers unchanged | | 2026-10-29 | minor | Bounded replay log (#1069, from a security audit): the batches a session keeps for apps that reconnect were bounded only by count (10,000 batches, 1,000,000 changes) on a desktop host and a private `figcraft-server`, so a long busy session could hold any number of bytes. `Limits::max_log_bytes` now defaults to 64 MiB, and `figcraft-server` takes it from a new `max_log_mb` setting (default 64; open to the public, also at most a quarter of `max_file_mb`, as before). An app further behind than the trimmed log catches up with the whole file, as it already did past the count limits. Nine 8 MiB renames with default limits kept all 75.5 MB before; now 58.7 MB, and a client that missed them gets a snapshot. The protocol is unchanged. | | 2026-10-29 | minor | M8, #998: MCP tools that act on a document (`run_command`, `batch`, `create_node`, `set_properties`, `inspect_document`, `get_node`, `select`, `export`, `screenshot`, `save_file`, `undo`, `redo`) take an optional `document`, an open file's `id` (`doc.list` and the `document.inspect` outline now carry it) or path, and so do the control methods `engine.execute`, `document.inspect`, `node.get` and `ui.render`: the call acts on that file and the tab in front stays in front, so an attached agent keeps editing its own file when the user switches tabs. Calls without it act on the file in front as before; other control methods refuse a `document` behind the tab in front. Numbers unchanged | diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs index 6b203c59..ab7f6544 100644 --- a/crates/engine/src/cmd/structure.rs +++ b/crates/engine/src/cmd/structure.rs @@ -613,7 +613,14 @@ fn layout_add(s: &mut Session, p: &Value) -> Result { let dir = string(p, "direction"); let doc = s.doc()?; let all_frames = ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none())); - let targets = if all_frames { + let single_group = ids.len() == 1 && ids.first().and_then(|i| doc.node(*i)).is_some_and(|n| matches!(n.kind, NodeKind::Group)); + let in_place = all_frames || single_group; + let targets = if in_place { + if single_group { + // Layout must arrange the group's children directly. Wrapping the group would + // leave them in a single fixed-size layout item and add an unnecessary layer. + convert(s, &json!({"ids": ids}), "layout.add", true)?; + } ids } else { // Wrap non-frames in a new frame without a fill, then add auto layout to it. @@ -646,7 +653,7 @@ fn layout_add(s: &mut Session, p: &Value) -> Result { let n = doc.get_mut(*f)?; n.children = kids; n.layout = Some(al); - if !all_frames || !empty { + if !in_place || !empty { // A frame wrapped around the selection hugs it; an existing frame with no flow // children keeps its size instead of collapsing to its padding. n.child_layout.sizing_h = Sizing::Hug; diff --git a/crates/engine/src/convert_container_tests.rs b/crates/engine/src/convert_container_tests.rs index be338a82..5bc55638 100644 --- a/crates/engine/src/convert_container_tests.rs +++ b/crates/engine/src/convert_container_tests.rs @@ -29,6 +29,56 @@ fn kind(s: &Session, n: NodeId) -> NodeKind { s.doc().unwrap().node(n).unwrap().kind.clone() } +#[test] +fn adding_auto_layout_converts_a_single_group_and_arranges_its_children_directly() { + let mut s = Session::new(); + let page = s.page().unwrap(); + let (a, b) = (rect(&mut s, page, 10.0, 20.0), rect(&mut s, page, 90.0, 20.0)); + let group = id(ex(&mut s, "object.group", json!({"ids": [a, b], "name": "Actions"}))); + let siblings = s.doc().unwrap().node(page).unwrap().children.clone(); + let before = [page_box(&s, group), page_box(&s, a), page_box(&s, b)]; + + let result = ex(&mut s, "layout.add", json!({"ids": [group], "direction": "horizontal"})); + assert_eq!(result["ids"], json!([group]), "the group's identity is retained"); + let doc = s.doc().unwrap(); + let frame = doc.node(group).unwrap(); + assert!(matches!(frame.kind, NodeKind::Frame)); + assert_eq!(frame.name, "Actions"); + assert_eq!(frame.parent, Some(page)); + assert_eq!(frame.children, vec![a, b], "no intermediate group or wrapper frame"); + assert!(frame.layout.is_some() && frame.fills.is_empty() && !frame.clip); + assert_eq!(doc.node(page).unwrap().children, siblings); + assert_eq!([page_box(&s, group), page_box(&s, a), page_box(&s, b)], before); + + // The second child now follows changes to the first through auto layout. + ex(&mut s, "node.set", json!({"id": a, "props": {"width": 60}})); + assert_eq!(page_box(&s, b), [110.0, 20.0, 150.0, 40.0]); + s.undo().unwrap(); + s.undo().unwrap(); + let restored = s.doc().unwrap().node(group).unwrap(); + assert!(matches!(restored.kind, NodeKind::Group) && restored.layout.is_none()); + assert_eq!(restored.children, vec![a, b]); + assert_eq!([page_box(&s, group), page_box(&s, a), page_box(&s, b)], before); + s.redo().unwrap(); + assert!(matches!(kind(&s, group), NodeKind::Frame)); + assert!(s.doc().unwrap().node(group).unwrap().layout.is_some()); +} + +#[test] +fn adding_auto_layout_to_multiple_groups_still_wraps_the_selection() { + let mut s = Session::new(); + let page = s.page().unwrap(); + let (a, b) = (rect(&mut s, page, 10.0, 20.0), rect(&mut s, page, 90.0, 20.0)); + let g1 = id(ex(&mut s, "object.group", json!({"ids": [a]}))); + let g2 = id(ex(&mut s, "object.group", json!({"ids": [b]}))); + let result = ex(&mut s, "layout.add", json!({"ids": [g1, g2]})); + let frame = crate::params::id_of(&result["ids"][0]).unwrap(); + let doc = s.doc().unwrap(); + assert!(matches!(doc.node(frame).unwrap().kind, NodeKind::Frame)); + assert_eq!(doc.node(frame).unwrap().children, vec![g1, g2]); + assert!(matches!(kind(&s, g1), NodeKind::Group) && matches!(kind(&s, g2), NodeKind::Group)); +} + #[test] fn a_frame_becomes_a_group_around_its_layers() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/panels/layout_panel.rs b/crates/ui-egui/src/panels/layout_panel.rs index 34516477..c2938539 100644 --- a/crates/ui-egui/src/panels/layout_panel.rs +++ b/crates/ui-egui/src/panels/layout_panel.rs @@ -26,6 +26,7 @@ fn parent_of(doc: &Document, id: NodeId) -> Option<&Node> { pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let ids = sel.to_vec(); let frames = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like())); + let layout_containers = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Group))); let al = common(doc, sel, |n| n.layout.clone()); let has_al = matches!(al, Some(Some(_))); let title = if has_al { tr("Auto layout") } else { tr("Layout") }; @@ -35,7 +36,7 @@ pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node let nested = sel.iter().any(|i| figcraft_engine::components::inside_instance(doc, *i)); let btns: Vec<(Icon, &str)> = if in_instance { vec![] - } else if frames && !has_al { + } else if layout_containers && !has_al { vec![(Icon::Plus, crate::shortcuts::tip(tr("Add auto layout"), "layout.add"))] } else if has_al { vec![(Icon::Minus, crate::shortcuts::tip(tr("Remove auto layout"), "layout.remove"))] diff --git a/docs/auto-layout-parity.md b/docs/auto-layout-parity.md index ea2cc54f..5b5d85a1 100644 --- a/docs/auto-layout-parity.md +++ b/docs/auto-layout-parity.md @@ -1,6 +1,6 @@ # Auto layout and constraints parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-17 · **Change:** minor (adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys only to frames, with warnings (#603); core editor leftovers: auto gap handles, wrapped-line alignment; behaviour suite written from the public documentation, every rule's status; fixes and the missing settings it found) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-29 · **Change:** minor (Layout sidebar: Add auto layout (+) is available for groups and converts a single group to a frame in place; adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys only to frames, with warnings (#603); core editor leftovers: auto gap handles, wrapped-line alignment; behaviour suite written from the public documentation, every rule's status; fixes and the missing settings it found) · **Target:** Figma Design (desktop app 126.10.7) Auto layout is the most used layout feature in Figma and the one where small differences (a hugging frame one pixel off, text wrapping at another width) break real files. **Breadth ~98%, @@ -9,6 +9,20 @@ positions it implies (`crates/layout/src/behaviour_tests.rs`, 64 cases, and `crates/engine/src/al_behaviour_tests.rs` through commands), and all pass; nothing yet against real Figma files. Remaining: 3–6 h. +## Adding auto layout to a group + +With one group selected, Add auto layout converts that group into a frame in place, retaining its +id, name, parent and direct children. The Layout sidebar’s +, context menu, Shift+A and API calls +all dispatch `layout.add`, so they share the conversion and a single undo step restores the group. +The layout settings are inferred from the children, as for an existing frame. + +This follows the Figma behavior reported by the contributor and the sidebar workflow users know from +similar design tools: start with a group and make its children participate in auto layout. +Wrapping the group would instead make the entire group one layout item, leaving its children +outside the new frame’s layout flow and adding an unnecessary level to the layer tree. Converting +the selected container makes later changes to a child’s size reflow its siblings directly. +Selecting several groups still wraps that selection in a shared auto layout frame. + ## Rules from the documentation Status: **pass** (a case in the suite holds), **fixed** (the suite found a divergence, now fixed), @@ -69,7 +83,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver | Arrow keys move children one place (flows, rows, columns, grid cells, into neighbours) | done | `al_keys_tests.rs` | | Constraints: left, right, left and right, centre, scale; top/bottom likewise | done | | | Layout grids: columns, rows, grid; stretch/left/centre/right; variables bound | done | | -| Suggest auto layout / "Add auto layout" on a selection | done | adding auto layout to an empty frame keeps its size, Fixed (it collapsed to 20×20); a frame with content still hugs it, as does a new frame wrapped around a selection (#369). `node.set` with auto layout keys on a mix of layers applies them to the frames and warns about the rest instead of failing the whole call (#603) | +| Suggest auto layout / "Add auto layout" on a selection | done | the Layout sidebar exposes Add auto layout (+) for groups and frames using the same `layout.add` command as the context menu; a single group becomes a frame in place, keeping its id, name and direct children; adding auto layout to an empty frame keeps its size, Fixed (it collapsed to 20×20); a frame with content still hugs it, as does a new frame wrapped around a selection (#369). `node.set` with auto layout keys on a mix of layers applies them to the frames and warns about the rest instead of failing the whole call (#603) | | Real Figma files laid out identically | **not checked** | needs `.fig` or REST import of real files ([gaps](gaps.md) #1, #3) | | Performance: re-flow only where sizes or places changed | done | measured in `cargo xtask bench` (components at scale) | @@ -77,6 +91,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M2: the Layout sidebar exposes Add auto layout (+) for groups. `layout.add` converts one selected group into a frame in place rather than wrapping it: same id, name and direct children; the layout arranges those children, and one undo restores the group. Sidebar, menu, shortcut and API calls all use the corrected command. This follows the contributor’s observed Figma behavior and the workflow familiar to users of similar design tools. Tests cover direct-child reflow, stable hierarchy, undo/redo and wrapping a multi-group selection. Numbers unchanged | | 2026-10-17 | minor | Community batch #753: adding auto layout to an empty frame keeps its size, Fixed, while a frame with content hugs it (#369); `node.set` applies auto layout keys to the frames among its targets and warns about the others, and a font style the family lacks warns with the nearest face (#603). Checked over MCP. Numbers unchanged | | 2026-10-13 | minor | Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged. | | 2026-10-13 | minor | Auto layout, wrapped-line alignment (M2): a wrapping layout's lines (rows of a horizontal one, columns of a vertical one) are packed where the cross alignment says (start, centre, end; before, they always sat at the start), stretch when every child fills across the flow, or take the free space between them (`contentAlign: spaceBetween`, the Design panel's Row/Column gap mode, the alignment pad lighting the whole cross axis). Read from REST JSON and `.fig`, written to `.fig`, `align-content` in CSS, an instance override with the other alignment settings. 13 hand-computed layout cases and command tests. Numbers unchanged. | From 38607aa70690dc1d6b649f1fdd97254d7e1afea9 Mon Sep 17 00:00:00 2001 From: Carbocode Date: Thu, 29 Oct 2026 15:04:12 +0100 Subject: [PATCH 32/63] M8: use Carbocode public GitHub profile name in contributor credits --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 14c2ad43..77553d44 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -95,3 +95,6 @@ sync_github_name = true [people.StrajnarFilip] display_name = "Filip Strajnar" + +[people.Carbocode] +sync_github_name = true From 0d9c2c3f733fd9e5582edec10de07cdba11ac1cf Mon Sep 17 00:00:00 2001 From: BFlat Date: Thu, 29 Oct 2026 21:05:32 +0700 Subject: [PATCH 33/63] M3: components that would contain themselves are refused, and loops in files are repaired (#1084) (#1540) * M2: components can't contain their own instances (#1084) Edits that would make a component hold an instance of itself, directly, through other components or through another variant of its set, are refused with the loop named. Files holding such a loop open with one instance per loop detached and a note; a rebuild never expands an instance inside a copy of itself, so a loop merged in a live session stops growing. * M2: a variant may hold a sibling variant's instance; only true loops are refused (#1084) Each variant counts as its own component: refusals and open-time repairs apply only to a component leading back to itself through instances (an instance-swap value included), so files with nested sibling variants open unchanged. sync_instance_depth is split into match_layers and copy_layers. --- ROADMAP.md | 3 +- crates/engine/src/assets/components.rs | 4 + crates/engine/src/assets/components/cycles.rs | 214 ++++++++++++++++ .../engine/src/assets/components/instances.rs | 5 +- crates/engine/src/assets/components/patch.rs | 2 +- crates/engine/src/assets/components/sync.rs | 161 +++++++----- crates/engine/src/cmd/component.rs | 4 +- crates/engine/src/cmd/component/instance.rs | 4 +- crates/engine/src/cmd/file/load.rs | 13 + crates/engine/src/component_cycle_tests.rs | 234 ++++++++++++++++++ crates/engine/src/lib.rs | 2 + crates/engine/src/session/exec.rs | 4 +- crates/engine/src/sharing/live/events.rs | 17 +- docs/components-parity.md | 3 +- docs/roadmap.md | 3 +- 15 files changed, 601 insertions(+), 72 deletions(-) create mode 100644 crates/engine/src/assets/components/cycles.rs create mode 100644 crates/engine/src/component_cycle_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index cf7c7cb6..9622e3d7 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-29 · **Change:** minor (`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-10-29 · **Change:** minor (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 @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M2: components can't contain their own instances (#1084). Any edit that would make a component hold an instance of itself, directly or through other components (an instance-swap property's current value included), is refused with the loop named: creating, placing, swapping, switching variants, moving, pasting and instance-swap values. Each variant counts as a component of its own, so a variant holding a sibling variant's instance is fine unless that one leads back to it (`components::check_component_cycles`, beside the instance structure check); a dropped instance lands on the page instead. A file holding such a loop (opened or imported) has one instance per loop detached, with a note; a loop merged in a live session is reported, and a rebuild never expands an instance inside a copy of itself, so its copies stop growing (12 rebuilds of a two-component loop: 25 → 113 layers before, 25 throughout now). Tests: `component_cycle_tests`. Numbers unchanged | | 2026-10-29 | minor | Which copy is which (M8, #995): `edit.duplicate` with `map: true` also returns `map`, each original layer's id, descendants included (nested frames, groups, instances and their sublayers), paired with its copy's id, so a batch can edit inside a copy through a step reference (`"field": "map/6"`) instead of inspecting it. Without `map` the result is unchanged. Regression tests in `crates/engine/src/duplicate_map_tests.rs`. Numbers unchanged | | 2026-10-29 | minor | Bounded replay log (#1069, from a security audit): the batches a session keeps for apps that reconnect were bounded only by count (10,000 batches, 1,000,000 changes) on a desktop host and a private `figcraft-server`, so a long busy session could hold any number of bytes. `Limits::max_log_bytes` now defaults to 64 MiB, and `figcraft-server` takes it from a new `max_log_mb` setting (default 64; open to the public, also at most a quarter of `max_file_mb`, as before). An app further behind than the trimmed log catches up with the whole file, as it already did past the count limits. Nine 8 MiB renames with default limits kept all 75.5 MB before; now 58.7 MB, and a client that missed them gets a snapshot. The protocol is unchanged. | | 2026-10-29 | minor | M8, #998: MCP tools that act on a document (`run_command`, `batch`, `create_node`, `set_properties`, `inspect_document`, `get_node`, `select`, `export`, `screenshot`, `save_file`, `undo`, `redo`) take an optional `document`, an open file's `id` (`doc.list` and the `document.inspect` outline now carry it) or path, and so do the control methods `engine.execute`, `document.inspect`, `node.get` and `ui.render`: the call acts on that file and the tab in front stays in front, so an attached agent keeps editing its own file when the user switches tabs. Calls without it act on the file in front as before; other control methods refuse a `document` behind the tab in front. Numbers unchanged | diff --git a/crates/engine/src/assets/components.rs b/crates/engine/src/assets/components.rs index 3192c3f1..c5a1c924 100644 --- a/crates/engine/src/assets/components.rs +++ b/crates/engine/src/assets/components.rs @@ -39,6 +39,7 @@ use figcraft_doc::{ use figcraft_geom::Affine; use serde_json::Value; +mod cycles; mod diff; mod guard; mod instances; @@ -47,6 +48,9 @@ mod rebase; mod sync; mod variants; +pub use cycles::{ + break_component_cycles, check_component_cycles, component_cycle, contains_itself_error, cycle_text, new_cycle, would_contain_itself, +}; pub use diff::{diff_groups, property_owners, set_boolean_default, variable_groups}; pub use guard::{check_override_edit, check_structure_edit, refused_override, unlink_layers}; pub(crate) use instances::copy_group; diff --git a/crates/engine/src/assets/components/cycles.rs b/crates/engine/src/assets/components/cycles.rs new file mode 100644 index 00000000..0d9b56f4 --- /dev/null +++ b/crates/engine/src/assets/components/cycles.rs @@ -0,0 +1,214 @@ +//! A component can't contain its own instances, directly or through other components (#1084): +//! every instance would show itself without end. Each variant is a component of its own here: one +//! holding an instance of another variant of its set is fine unless that one leads back to it. +//! Commands that would close such a loop are refused ([`check_component_cycles`]); +//! files that hold one are broken on opening ([`break_component_cycles`]); and a rebuild never +//! expands an instance inside a copy of itself ([`expansion_layers`]), for loops that arrive some +//! other way (two edits merged in a live session). + +use super::patch::rebuild_instances; +use super::{BTreeMap, BTreeSet, Document, NodeId, NodeKind, PROP_BINDING, PropDef, inside_instance, root_instance}; + +/// The components each main component holds instances of. +type Graph = BTreeMap>; + +fn is_component(doc: &Document, id: NodeId) -> bool { + doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. })) +} + +/// The main components `id` is (in). +fn hosts(doc: &Document, id: NodeId) -> BTreeSet { + std::iter::once(id).chain(doc.ancestors(id)).filter(|a| is_component(doc, *a)).collect() +} + +/// The components instance `id` shows: its main, and those its instance swap properties pick +/// (they swap a layer inside it when it's rebuilt). +fn shown(doc: &Document, id: NodeId) -> Vec { + let Some(NodeKind::Instance { component, props }) = doc.node(id).map(|n| &n.kind) else { return Vec::new() }; + let Some(NodeKind::Component { props: defs, .. }) = doc.node(*component).map(|n| &n.kind) else { return Vec::new() }; + let swaps = + defs.iter().filter(|(_, d)| matches!(d, PropDef::InstanceSwap { .. })).filter_map(|(k, _)| props.get(k).and_then(crate::params::id_of)); + std::iter::once(*component).chain(swaps).filter(|c| is_component(doc, *c)).collect() +} + +fn graph(doc: &Document) -> Graph { + let mut g = Graph::new(); + for n in doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { .. })) { + let shown = shown(doc, n.id); + for h in hosts(doc, n.id) { + g.entry(h).or_default().extend(shown.iter().copied()); + } + } + g +} + +/// A loop in `g`, its first component repeated last (`A, B, A`). +fn find_cycle(g: &Graph) -> Option> { + let next = |u: &NodeId| -> Vec { g.get(u).map(|s| s.iter().rev().copied().collect()).unwrap_or_default() }; + let mut done: BTreeSet = BTreeSet::new(); + for start in g.keys() { + if done.contains(start) { + continue; + } + // Depth first: the path so far, and the components each step on it has left to visit. + let mut path = vec![*start]; + let mut todo = vec![next(start)]; + while let Some(left) = todo.last_mut() { + match left.pop() { + Some(u) if path.contains(&u) => { + let at = path.iter().position(|p| *p == u)?; + let mut cycle = path.split_off(at); + cycle.push(u); + return Some(cycle); + } + Some(u) if done.contains(&u) => {} + Some(u) => { + todo.push(next(&u)); + path.push(u); + } + None => { + todo.pop(); + done.extend(path.pop()); + } + } + } + } + None +} + +/// A loop of components whose mains contain each other's instances, if the document has one. +pub fn component_cycle(doc: &Document) -> Option> { + find_cycle(&graph(doc)) +} + +/// A loop as its names: `Card → Button → Card`. +fn cycle_names(doc: &Document, cycle: &[NodeId]) -> String { + cycle.iter().map(|u| doc.node(*u).map_or_else(|| u.to_string(), |n| format!("\"{}\"", n.name))).collect::>().join(" → ") +} + +/// Whether an instance of `component` under `parent` would contain itself: `parent` is (in) a +/// main that instance shows, directly or through other components. +pub fn would_contain_itself(doc: &Document, component: NodeId, parent: NodeId) -> bool { + let hosts = hosts(doc, parent); + if hosts.is_empty() { + return false; + } + let g = graph(doc); + let mut seen = BTreeSet::new(); + let mut todo = vec![component]; + while let Some(u) = todo.pop() { + if hosts.contains(&u) { + return true; + } + if seen.insert(u) { + todo.extend(g.get(&u).into_iter().flatten().copied()); + } + } + false +} + +/// Why an instance of `component` can't go where it would contain itself. +pub fn contains_itself_error(doc: &Document, component: NodeId) -> String { + let name = doc.node(component).map(|n| n.name.clone()).unwrap_or_default(); + format!("an instance of \"{name}\" can't go inside \"{name}\" or a component it contains: a component can't contain itself") +} + +/// The loop `after` has and `before` didn't, if layers `touched` (edited, added or removed) closed +/// one: only layers in main components can. +pub fn new_cycle<'a>(before: &Document, after: &Document, mut touched: impl Iterator) -> Option { + if !touched.any(|d| !hosts(after, *d).is_empty()) { + return None; + } + cycle_text(after).filter(|_| component_cycle(before).is_none()) +} + +/// The document's loop of components containing each other's instances ([`component_cycle`]), +/// as its names. +pub fn cycle_text(doc: &Document) -> Option { + component_cycle(doc).map(|c| cycle_names(doc, &c)) +} + +/// Refuse an edit that makes components contain each other's instances (it is rolled back). A +/// document that already held a loop stays editable. +pub fn check_component_cycles(before: &Document, after: &Document) -> Result<(), String> { + match new_cycle(before, after, after.dirty().iter()) { + Some(names) => Err(format!("a component can't contain an instance of itself, directly or through other components: {names}")), + None => Ok(()), + } +} + +/// The instance to detach to cut `from → to` out of a loop: one in a main of `from` showing `to`, +/// preferably one of the main's own (not a layer of another instance, else its outer instance). +fn cut_point(doc: &Document, from: NodeId, to: NodeId) -> Option { + let found: Vec = doc + .nodes() + .filter(|n| matches!(n.kind, NodeKind::Instance { .. })) + .map(|n| n.id) + .filter(|i| shown(doc, *i).contains(&to) && hosts(doc, *i).contains(&from)) + .collect(); + found.iter().copied().find(|i| !inside_instance(doc, *i)).or_else(|| found.first().and_then(|i| root_instance(doc, *i))) +} + +/// Detach instance `top` and every instance inside it: its layers stay as they are, linked to no +/// main. +fn detach_all(doc: &mut Document, top: NodeId) { + for d in doc.subtree(top) { + let modes = match doc.node(d).map(|n| &n.kind) { + Some(NodeKind::Instance { component, .. }) => doc.node(*component).map(|m| m.var_modes.clone()), + _ => None, + }; + let Some(n) = doc.node_mut(d) else { continue }; + if let Some(modes) = modes { + n.kind = NodeKind::Frame; + n.bound.retain(|k, _| !k.starts_with(PROP_BINDING)); + for (c, m) in modes { + n.var_modes.entry(c).or_insert(m); + } + } + n.source = None; + n.overrides.clear(); + n.prop_refs.clear(); + } +} + +/// Break every loop of components containing each other's instances (a file from elsewhere, or +/// written before they were refused): an instance closing each loop is detached, and the instances +/// of its main rebuild without it. Returns a note for each. +pub fn break_component_cycles(doc: &mut Document) -> Vec { + let mut notes = Vec::new(); + // Each pass detaches an instance: never more passes than there are layers. + for _ in 0..doc.nodes().count() { + let Some(cycle) = component_cycle(doc) else { break }; + let (Some(from), Some(to)) = (cycle.iter().rev().nth(1).copied(), cycle.last().copied()) else { break }; + let Some(cut) = cut_point(doc, from, to) else { break }; + let name = doc.node(cut).map(|n| n.name.clone()).unwrap_or_default(); + detach_all(doc, cut); + rebuild_instances(doc, &BTreeSet::from([from])); + notes.push(format!("\"{name}\" was detached: components can't contain their own instances ({})", cycle_names(doc, &cycle))); + } + notes +} + +/// The layers of `component` an instance `inst` of it copies, in pre-order: all of them but those +/// inside a nested instance of a component being expanded already (its own, or a main `inst` is +/// in), which would show itself again. `None` when `inst` itself is in its own main. +pub(super) fn expansion_layers(doc: &Document, inst: NodeId, component: NodeId) -> Option> { + let mut stops = hosts(doc, inst); + if !stops.insert(component) { + return None; + } + let repeats = |kind: &NodeKind| matches!(kind, NodeKind::Instance { component: c, .. } if stops.contains(c)); + let mut out = Vec::new(); + let mut stack: Vec = doc.node(component).map(|n| n.children.iter().rev().copied().collect()).unwrap_or_default(); + while let Some(id) = stack.pop() { + let Some(n) = doc.node(id) else { continue }; + out.push(id); + if out.len() > 5_000_000 { + break; + } + if !repeats(&n.kind) { + stack.extend(n.children.iter().rev()); + } + } + Some(out) +} diff --git a/crates/engine/src/assets/components/instances.rs b/crates/engine/src/assets/components/instances.rs index ac7ff4ec..e7b3114a 100644 --- a/crates/engine/src/assets/components/instances.rs +++ b/crates/engine/src/assets/components/instances.rs @@ -1,3 +1,4 @@ +use super::cycles::{contains_itself_error, would_contain_itself}; use super::diff::{binding_group, bindings_of}; use super::sync::{insert_linked, sync_instance_depth}; use super::{ @@ -16,8 +17,8 @@ pub fn create_instance_at(doc: &mut Document, component: NodeId, parent: NodeId, if !matches!(comp.kind, NodeKind::Component { .. }) { return Err(DocError::Invalid(format!("{component} is not a component"))); } - if doc.is_ancestor(component, parent) { - return Err(DocError::Invalid("an instance can't be placed inside its own main component".into())); + if would_contain_itself(doc, component, parent) { + return Err(DocError::Invalid(contains_itself_error(doc, component))); } let id = doc.alloc_id(); let mut root = comp.clone(); diff --git a/crates/engine/src/assets/components/patch.rs b/crates/engine/src/assets/components/patch.rs index 8ae01bc1..92d2e2f2 100644 --- a/crates/engine/src/assets/components/patch.rs +++ b/crates/engine/src/assets/components/patch.rs @@ -500,7 +500,7 @@ fn sized_own(n: &Node) -> bool { /// Rebuild every instance of `comps` (and of the components that hold those instances, and so on /// outwards), each outermost instance once, inner components first. -fn rebuild_instances(doc: &mut Document, comps: &BTreeSet) { +pub(super) fn rebuild_instances(doc: &mut Document, comps: &BTreeSet) { if comps.is_empty() { return; } diff --git a/crates/engine/src/assets/components/sync.rs b/crates/engine/src/assets/components/sync.rs index e81bf437..7b34c33c 100644 --- a/crates/engine/src/assets/components/sync.rs +++ b/crates/engine/src/assets/components/sync.rs @@ -1,3 +1,4 @@ +use super::cycles::expansion_layers; use super::diff::diff_groups; use super::instances::{Carry, apply_prop, carry_overrides, carry_prop_bindings, copy_group, field_group, follow_variant_resizing, path_map}; use super::{ @@ -23,26 +24,76 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize // Old layers (kept alive for their overrides), by (parent, source), by source alone (when only // one layer mirrors it) and, only when those don't match, by name path. let (mut old, by_source, by_src) = index_old_layers(doc, inst, spare); + // Never a copy of itself inside it (components containing each other). + let Some(src) = expansion_layers(doc, inst, component) else { return }; + let Matched { mut map, used, edited, fresh } = match_layers(doc, inst, component, &src, &mut old, &by_source, &by_src); + for c in root.children.clone() { + let _ = doc.remove(c); + } + for s in fresh { + let nid = doc.alloc_id(); + map.insert(s, nid); + } + let Copies { nodes: new_nodes, nested, mut remeasure } = copy_layers(doc, inst, &src, &map, &old, &edited, eff_props); + for n in new_nodes { + let (id, parent) = (n.id, n.parent); + if insert_linked(doc, n).is_err() + && let Some(p) = parent.and_then(|p| doc.node_mut(p)) + { + p.children.retain(|c| *c != id); + } + } + let fresh = fresh_root(doc, &mut root, &comp, inst, component, &map); + if let Some(r) = doc.node_mut(inst) { + *r = fresh; + } + // A resized instance: children follow their constraints (auto layout re-flows later). + let (iw, ih) = doc.node(inst).map(|n| (n.width, n.height)).unwrap_or((comp.width, comp.height)); + if (iw - comp.width).abs() > 1e-9 || (ih - comp.height).abs() > 1e-9 { + figcraft_layout::apply_constraints(doc, inst, comp.width, comp.height); + remeasure = map.values().copied().collect(); + } + follow_geometry_overrides(doc, &src, &map, &old, &mut remeasure); + remeasure_text(doc, remeasure); + rebuild_nested(doc, nested, &old, &used, depth); +} + +/// Which instance layer each main layer a rebuild copies becomes. +struct Matched { + /// Main layer → its copy's id (the matching old layer's, so a layer the main moved to another + /// parent keeps its id and overrides); the main itself → the instance. + map: BTreeMap, + /// The old layers reused. + used: BTreeSet, + /// Slots holding the instance's own content (their main layers' default content isn't copied). + edited: BTreeSet, + /// Main layers no old layer matches: their copies get new ids. + fresh: Vec, +} + +/// Match the main layers `src` of instance `inst`'s main `component` to its old layers: by +/// (parent, source), by source alone (when only one layer mirrors it) and, only when those don't +/// match, by name path. Old layers matched across variants drop what the two variants differ in. +fn match_layers( + doc: &Document, + inst: NodeId, + component: NodeId, + src: &[NodeId], + old: &mut BTreeMap>, + by_source: &BTreeMap<(NodeId, NodeId), NodeId>, + by_src: &BTreeMap>, +) -> Matched { let mut by_path: Option> = None; let mut main_paths: Option> = None; - // Ids for the copies: reuse the matching old layer's id (a layer the main moved to another - // parent keeps its id and overrides). - let src: Vec = doc.subtree(component).into_iter().filter(|i| *i != component).collect(); - let mut map: BTreeMap = BTreeMap::new(); - map.insert(component, inst); - let mut used: BTreeSet = BTreeSet::new(); - // Slots holding the instance's own content (new ids), and main layers not copied: the - // default content of those slots. - let mut edited: BTreeSet = BTreeSet::new(); + let mut m = Matched { map: BTreeMap::from([(component, inst)]), used: BTreeSet::new(), edited: BTreeSet::new(), fresh: Vec::new() }; let mut skipped: BTreeSet = BTreeSet::new(); - let mut fresh_ids = Vec::new(); - for s in &src { + for s in src { let Some(sn) = doc.node(*s) else { continue }; - if sn.parent.is_some_and(|p| skipped.contains(&p) || map.get(&p).is_some_and(|pn| edited.contains(pn))) { + if sn.parent.is_some_and(|p| skipped.contains(&p) || m.map.get(&p).is_some_and(|pn| m.edited.contains(pn))) { skipped.insert(*s); continue; } - let parent_new = sn.parent.and_then(|p| map.get(&p).copied()); + let parent_new = sn.parent.and_then(|p| m.map.get(&p).copied()); let mut found = parent_new.and_then(|pn| by_source.get(&(pn, *s)).copied()).or_else(|| by_src.get(s).copied().flatten()); if found.is_none() { let paths = by_path.get_or_insert_with(|| path_map(doc, inst, true).into_iter().map(|(k, v)| (v, k)).collect()); @@ -50,37 +101,47 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize found = mains.get(s).and_then(|p| paths.get(p)).copied(); } let nid = match found { - Some(o) if used.insert(o) => o, + Some(o) if m.used.insert(o) => o, _ => { - fresh_ids.push(*s); + m.fresh.push(*s); continue; } }; - if let Some(m) = old.get(&nid).and_then(|o| variant_kept(doc, o, *s)) { - old.insert(nid, std::sync::Arc::new(m)); + if let Some(kept) = old.get(&nid).and_then(|o| variant_kept(doc, o, *s)) { + old.insert(nid, std::sync::Arc::new(kept)); } if old.get(&nid).is_some_and(|o| holds_content(o)) && is_slot(doc, *s) { - edited.insert(nid); + m.edited.insert(nid); } - map.insert(*s, nid); + m.map.insert(*s, nid); } - for c in root.children.clone() { - let _ = doc.remove(c); - } - for s in fresh_ids { - let nid = doc.alloc_id(); - map.insert(s, nid); - } - // Build the copies (pre-order: parents first). - let mut enclosing: BTreeMap> = BTreeMap::new(); - enclosing.insert(inst, vec![inst]); - let mut inst_props: BTreeMap> = BTreeMap::new(); - inst_props.insert(inst, eff_props); - let mut nested = Vec::new(); - let mut new_nodes = Vec::new(); - // Text layers showing other text or style than their main layer (to re-measure). - let mut remeasure = Vec::new(); - for s in &src { + m +} + +/// A rebuild's copies of the main's layers, pre-order (parents first). +struct Copies { + nodes: Vec, + /// Nested instances showing another main than their main layer (swapped): rebuilt from it. + nested: Vec, + /// Text layers showing other text or style than their main layer (to re-measure). + remeasure: Vec, +} + +/// Copy main layers `src` into instance `inst` as `map` names them, with the overrides of the +/// `old` layers they replace and the component property values (`props`: the instance's). +fn copy_layers( + doc: &Document, + inst: NodeId, + src: &[NodeId], + map: &BTreeMap, + old: &BTreeMap>, + edited: &BTreeSet, + props: BTreeMap, +) -> Copies { + let mut enclosing: BTreeMap> = BTreeMap::from([(inst, vec![inst])]); + let mut inst_props: BTreeMap> = BTreeMap::from([(inst, props)]); + let mut out = Copies { nodes: Vec::new(), nested: Vec::new(), remeasure: Vec::new() }; + for s in src { let Some(orig) = doc.node(*s) else { continue }; let Some(&nid) = map.get(s) else { continue }; let Some(parent) = orig.parent.and_then(|p| map.get(&p).copied()) else { continue }; @@ -113,7 +174,7 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize e.push(nid); enclosing.insert(nid, e); if !matches!(orig.kind, NodeKind::Instance { component: oc, .. } if oc == *c) { - nested.push(nid); + out.nested.push(nid); } } else { enclosing.insert(nid, enc); @@ -121,31 +182,11 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize if let (NodeKind::Text(a), NodeKind::Text(b)) = (&orig.kind, &n.kind) && a != b { - remeasure.push(nid); + out.remeasure.push(nid); } - new_nodes.push(n); + out.nodes.push(n); } - for n in new_nodes { - let (id, parent) = (n.id, n.parent); - if insert_linked(doc, n).is_err() - && let Some(p) = parent.and_then(|p| doc.node_mut(p)) - { - p.children.retain(|c| *c != id); - } - } - let fresh = fresh_root(doc, &mut root, &comp, inst, component, &map); - if let Some(r) = doc.node_mut(inst) { - *r = fresh; - } - // A resized instance: children follow their constraints (auto layout re-flows later). - let (iw, ih) = doc.node(inst).map(|n| (n.width, n.height)).unwrap_or((comp.width, comp.height)); - if (iw - comp.width).abs() > 1e-9 || (ih - comp.height).abs() > 1e-9 { - figcraft_layout::apply_constraints(doc, inst, comp.width, comp.height); - remeasure = map.values().copied().collect(); - } - follow_geometry_overrides(doc, &src, &map, &old, &mut remeasure); - remeasure_text(doc, remeasure); - rebuild_nested(doc, nested, &old, &used, depth); + out } /// Geometry overrides of children win over constraints. diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index 0f59f41e..969fe474 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -292,8 +292,8 @@ pub(crate) fn place(s: &mut Session, p: &Value) -> Result { let doc = s.doc_mut()?; let c = main_of(doc, c).ok_or_else(|| bad(cmd, "`component` must be a component or component set"))?; let mut slot = crate::drop::slot(doc, page, pt); - // Never inside its own main component (or a copy of it). - if doc.is_ancestor(c, slot.parent) || slot.parent == c { + // Never inside its own main component, or one it contains (directly or through others). + if crate::components::would_contain_itself(doc, c, slot.parent) { slot = crate::drop::DropSlot { parent: page, index: None, line: None }; } let (w, h) = doc.node(c).map(|n| (n.width, n.height)).unwrap_or((0.0, 0.0)); diff --git a/crates/engine/src/cmd/component/instance.rs b/crates/engine/src/cmd/component/instance.rs index f6600c10..c6f735e2 100644 --- a/crates/engine/src/cmd/component/instance.rs +++ b/crates/engine/src/cmd/component/instance.rs @@ -376,8 +376,8 @@ pub(super) fn swap(s: &mut Session, p: &Value) -> Result { /// Make instance `i` show main component `c`, keeping the values of properties with the same names /// (the layers' overrides follow by name when the instance is rebuilt). pub(crate) fn swap_instance(doc: &mut Document, i: NodeId, c: NodeId) -> std::result::Result<(), String> { - if doc.is_ancestor(c, i) { - return Err("an instance can't show the component it is inside".into()); + if crate::components::would_contain_itself(doc, c, i) { + return Err(crate::components::contains_itself_error(doc, c)); } let defaults = doc.node(c).map(default_props).unwrap_or_default(); match doc.get_mut(i).map(|n| &mut n.kind) { diff --git a/crates/engine/src/cmd/file/load.rs b/crates/engine/src/cmd/file/load.rs index 4e596bc6..01b36eda 100644 --- a/crates/engine/src/cmd/file/load.rs +++ b/crates/engine/src/cmd/file/load.rs @@ -70,11 +70,24 @@ pub fn load_path(path: &str, variables: Option<&Value>) -> Result<(figcraft_form /// Load from bytes. Returns the document with its notes (repairs of a native file, or import /// warnings) and version history, and whether it was imported from REST API file JSON or a `.fig` /// file (such a file is never saved over). `variables`: the local variables JSON for a REST API file JSON. +/// Components containing each other's instances are broken apart, with a note. pub(super) fn load_with( bytes: &[u8], name_hint: &str, variables: Option<&Value>, images: figcraft_format::rest::ImageResolver, +) -> Result<(figcraft_format::Loaded, bool)> { + let (mut loaded, rest) = load_any(bytes, name_hint, variables, images)?; + let notes = crate::components::break_component_cycles(&mut loaded.doc); + loaded.issues.extend(notes); + Ok((loaded, rest)) +} + +fn load_any( + bytes: &[u8], + name_hint: &str, + variables: Option<&Value>, + images: figcraft_format::rest::ImageResolver, ) -> Result<(figcraft_format::Loaded, bool)> { match ext(name_hint).as_str() { "svg" => { diff --git a/crates/engine/src/component_cycle_tests.rs b/crates/engine/src/component_cycle_tests.rs new file mode 100644 index 00000000..1b8b7ce2 --- /dev/null +++ b/crates/engine/src/component_cycle_tests.rs @@ -0,0 +1,234 @@ +//! Components can't contain their own instances, directly, through other components or through +//! the variants of a set (#1084); files holding such a loop open with it broken. + +use std::sync::Arc; + +use base64::Engine; +use figcraft_doc::{Document, NodeId, NodeKind}; +use serde_json::{Value, json}; + +use crate::Session; +use crate::components::component_cycle; +use crate::components::tests::{copy_of, ex, idv, kids, node}; + +/// A 100×50 component named `name` at (x, 0), holding a rectangle; returns its id. +fn component(s: &mut Session, name: &str, x: f64) -> NodeId { + let f = idv(&ex(s, "frame.create", json!({"x": x, "y": 0, "width": 100, "height": 50, "name": name}))); + ex(s, "shape.rectangle", json!({"x": 5, "y": 5, "width": 10, "height": 10, "parent": f})); + idv(&ex(s, "component.create", json!({"ids": [f]}))) +} + +fn instance(s: &mut Session, component: NodeId, parent: NodeId) -> NodeId { + idv(&ex(s, "instance.create", json!({"component": component, "x": 20, "y": 20, "parent": parent}))) +} + +/// `cmd` is refused with the loop explained, and the document is left as it was. +fn refused(s: &mut Session, cmd: &str, p: Value) { + let before = s.doc().unwrap().clone(); + let undo = s.ds().unwrap().history.undo.len(); + let err = s.execute(cmd, &p).expect_err(&format!("{cmd} {p} should be refused")).to_string(); + assert!(err.contains("can't contain"), "{cmd}: {err}"); + assert_eq!(*s.doc().unwrap(), before, "{cmd} rolled back"); + assert_eq!(s.ds().unwrap().history.undo.len(), undo, "{cmd} left no undo step"); +} + +/// Card holds a Badge instance; Badge holds an Icon instance. Returns (card, badge, icon, the +/// Icon instance in Badge). +fn chain(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId) { + let icon = component(s, "Icon", 0.0); + let badge = component(s, "Badge", 200.0); + let card = component(s, "Card", 400.0); + let in_badge = instance(s, icon, badge); + instance(s, badge, card); + (card, badge, icon, in_badge) +} + +#[test] +fn direct_and_indirect_loops_are_refused() { + let mut s = Session::new(); + let (card, badge, _, in_badge) = chain(&mut s); + // Directly, and through Badge: Card already shows Badge. + refused(&mut s, "instance.create", json!({"component": card, "parent": card})); + refused(&mut s, "instance.create", json!({"component": card, "parent": badge})); + refused(&mut s, "instance.swap", json!({"ids": [in_badge], "component": card})); + // A Card instance moved or pasted into Badge. + let page = s.page().unwrap(); + let loose = instance(&mut s, card, page); + refused(&mut s, "node.reparent", json!({"ids": [loose], "parent": badge})); + ex(&mut s, "edit.copy", json!({"ids": [loose]})); + refused(&mut s, "edit.paste", json!({"parent": badge})); + // A copied main pastes as an instance of it. + ex(&mut s, "edit.copy", json!({"ids": [card]})); + refused(&mut s, "edit.paste", json!({"parent": badge})); + assert!(component_cycle(s.doc().unwrap()).is_none()); +} + +#[test] +fn dropping_an_instance_where_it_would_contain_itself_places_it_on_the_page() { + let mut s = Session::new(); + let (card, badge, _, _) = chain(&mut s); + let r = ex(&mut s, "instance.place", json!({"component": card, "x": 250, "y": 25})); + assert_eq!(r["parent"], json!(s.page().unwrap()), "not into Badge: {r}"); + assert!(!s.doc().unwrap().is_ancestor(badge, idv(&r))); +} + +#[test] +fn an_instance_swap_property_can_not_close_a_loop() { + let mut s = Session::new(); + let (card, badge, icon, in_badge) = chain(&mut s); + ex(&mut s, "component.addProperty", json!({"id": badge, "name": "Icon", "type": "instanceSwap", "default": icon, "layer": in_badge})); + let in_card = kids(&s, card).into_iter().find(|k| matches!(node(&s, *k).kind, NodeKind::Instance { .. })).unwrap(); + refused(&mut s, "instance.setProperties", json!({"ids": [in_card], "properties": {"Icon": card}})); + // Another component is fine. + let star = component(&mut s, "Star", 600.0); + ex(&mut s, "instance.setProperties", json!({"ids": [in_card], "properties": {"Icon": star}})); +} + +/// A set of two variants, "State=On" holding an instance of "State=Off". Returns (on, off, the Off +/// instance in On). +fn nested_variants(s: &mut Session) -> (NodeId, NodeId, NodeId) { + let on = component(s, "State=On", 0.0); + let off = component(s, "State=Off", 200.0); + ex(s, "component.combineVariants", json!({"ids": [on, off]})); + let held = instance(s, off, on); + (on, off, held) +} + +#[test] +fn a_variant_holds_a_sibling_but_no_loop_back_through_it() { + let mut s = Session::new(); + let (on, off, held) = nested_variants(&mut s); + assert!(component_cycle(s.doc().unwrap()).is_none(), "a sibling variant's instance is no loop"); + // Off leading back to On, directly or through another component, is one. + refused(&mut s, "instance.create", json!({"component": on, "parent": off})); + let row = component(&mut s, "Row", 600.0); + instance(&mut s, on, row); + refused(&mut s, "instance.create", json!({"component": row, "parent": off})); + // So is switching the held instance to the variant holding it. + refused(&mut s, "instance.setProperties", json!({"ids": [held], "properties": {"State": "On"}})); + // Combining a component with one holding its instance is fine too. + let a = component(&mut s, "A", 0.0); + let b = component(&mut s, "B", 200.0); + instance(&mut s, a, b); + ex(&mut s, "component.combineVariants", json!({"ids": [a, b]})); + assert!(component_cycle(s.doc().unwrap()).is_none()); +} + +/// `doc` saved and opened again: the session and what opening it reported. +fn reopened(doc: &Document) -> (Session, Value) { + let data = base64::engine::general_purpose::STANDARD.encode(figcraft_format::save_bytes(doc, None).unwrap()); + let mut s = Session::new(); + let r = ex(&mut s, "file.open", json!({"data": data, "name": "variants.figcraft"})); + (s, r) +} + +#[test] +fn a_file_with_nested_sibling_variants_opens_unchanged_and_a_loop_through_them_is_repaired() { + let mut s = Session::new(); + let (on, off, held) = nested_variants(&mut s); + let doc = s.doc().unwrap().clone(); + let (t, r) = reopened(&doc); + assert_eq!(r["repairs"], json!([]), "{r}"); + assert_eq!(t.doc().unwrap().nodes().count(), doc.nodes().count()); + assert!(matches!(node(&t, held).kind, NodeKind::Instance { component, .. } if component == off)); + // On → Off → On: an On instance in Off, written directly as an older file could have it. + let spare = component(&mut s, "Spare", 600.0); + let back = instance(&mut s, spare, off); + let mut doc = s.doc().unwrap().clone(); + if let NodeKind::Instance { component, .. } = &mut doc.get_mut(back).unwrap().kind { + *component = on; + } + assert!(component_cycle(&doc).is_some()); + let (t, r) = reopened(&doc); + assert!(r["repairs"].as_array().is_some_and(|n| n.iter().any(|n| n.as_str().is_some_and(|n| n.contains("was detached")))), "{r}"); + assert!(component_cycle(t.doc().unwrap()).is_none()); +} + +#[test] +fn components_around_instances_and_instances_in_slots_stay_allowed() { + let mut s = Session::new(); + let (card, badge, ..) = chain(&mut s); + // A new component around a Card instance. + let page = s.page().unwrap(); + let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 200, "height": 100, "parent": page}))); + instance(&mut s, card, f); + let holder = idv(&ex(&mut s, "component.create", json!({"ids": [f]}))); + instance(&mut s, badge, holder); + // An instance in a slot of an instance of its own component: the slot holds that instance's + // own content, outside its main. + let slot = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 50, "height": 20, "parent": card, "name": "Slot"}))); + ex(&mut s, "component.makeSlot", json!({"id": slot})); + let outer = instance(&mut s, card, page); + let outer_slot = copy_of(&s, outer, slot); + instance(&mut s, card, outer_slot); + assert!(component_cycle(s.doc().unwrap()).is_none()); +} + +/// A document where Card holds a Badge instance and Badge a Card instance, made by editing the +/// layers directly (as an older file or a merge could have it). Returns it with (card, badge). +fn looped() -> (Document, NodeId, NodeId) { + let mut s = Session::new(); + let (card, badge, _, in_badge) = chain(&mut s); + let mut doc = s.doc().unwrap().clone(); + if let NodeKind::Instance { component, .. } = &mut doc.get_mut(in_badge).unwrap().kind { + *component = card; + } + crate::components::sync_instance(&mut doc, in_badge); + assert!(component_cycle(&doc).is_some()); + (doc, card, badge) +} + +/// Edit mains `a` and `b` `n` times, every instance rebuilt in full each time; the layer count +/// after each round. +fn edit_mains(s: &mut Session, a: NodeId, b: NodeId, n: usize) -> Vec { + crate::components::FULL_REBUILD.with(|f| f.set(true)); + let sizes = (0..n) + .map(|i| { + ex(s, "node.set", json!({"id": a, "props": {"fill": format!("#{:02x}0000", i * 20)}})); + ex(s, "node.set", json!({"id": b, "props": {"fill": format!("#00{:02x}00", i * 20)}})); + s.doc().unwrap().nodes().count() + }) + .collect(); + crate::components::FULL_REBUILD.with(|f| f.set(false)); + sizes +} + +#[test] +fn a_file_with_a_loop_opens_with_it_broken_and_a_note() { + let (doc, card, badge) = looped(); + let bytes = figcraft_format::save_bytes(&doc, None).unwrap(); + let data = base64::engine::general_purpose::STANDARD.encode(&bytes); + let mut s = Session::new(); + let r = ex(&mut s, "file.open", json!({"data": data, "name": "looped.figcraft"})); + let notes = r["repairs"].as_array().cloned().unwrap_or_default(); + assert!(notes.iter().any(|n| n.as_str().is_some_and(|n| n.contains("was detached"))), "{r}"); + let doc = s.doc().unwrap(); + assert!(component_cycle(doc).is_none()); + // The detached instance kept its layers; the other side is still an instance. + assert!(doc.contains(card) && doc.contains(badge)); + let count = doc.nodes().count(); + // Editing the mains no longer grows the file. + assert_eq!(edit_mains(&mut s, card, badge, 10), vec![count; 10]); + // Importing it into another file breaks it the same way. + let mut t = Session::new(); + let r = ex(&mut t, "file.import", json!({"data": data, "name": "looped.figcraft"})); + assert!(r["warnings"].as_array().is_some_and(|w| !w.is_empty()), "{r}"); + assert!(component_cycle(t.doc().unwrap()).is_none()); +} + +#[test] +fn a_loop_merged_in_stops_expanding_and_the_document_stays_editable() { + let (doc, card, badge) = looped(); + let mut s = Session::new(); + let uid = s.ds().unwrap().uid; + s.apply_remote(uid, Arc::new(doc)); + assert!(s.message.as_deref().is_some_and(|m| m.contains("contain each other")), "{:?}", s.message); + let sizes = edit_mains(&mut s, card, badge, 12); + // A couple of rebuilds settle the copies; after that nothing grows. + assert!(sizes.windows(2).skip(2).all(|w| w[0] == w[1]), "{sizes:?}"); + // Editing goes on: layers added to a main reach its instances. + ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 4, "height": 4, "parent": card})); + let page = s.page().unwrap(); + let loose = instance(&mut s, badge, page); + assert!(!kids(&s, loose).is_empty()); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 872f3cd3..2b4e7bb6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -229,6 +229,8 @@ mod comments_polish_tests; #[cfg(test)] mod component_codegen_tests; #[cfg(test)] +mod component_cycle_tests; +#[cfg(test)] mod components_behaviour_tests; #[cfg(test)] mod components_scale_tests; diff --git a/crates/engine/src/session/exec.rs b/crates/engine/src/session/exec.rs index 633bf9db..17d191cc 100644 --- a/crates/engine/src/session/exec.rs +++ b/crates/engine/src/session/exec.rs @@ -193,7 +193,8 @@ impl Session { } /// Instances can't gain, lose or reorder layers, nor move, resize or re-constrain the layers in - /// them, and library copies are read-only: refuse such an edit (it is rolled back). + /// them, library copies are read-only and components can't contain their own instances: refuse + /// such an edit (it is rolled back). pub(crate) fn check_instance_structure(&self, id: &str, before: &Option<(Arc, Vec, NodeId, u64)>) -> Result<()> { let Some((bdoc, _, _, uid)) = before else { return Ok(()) }; let Some(ds) = self.docs.iter().find(|d| d.uid == *uid) else { return Ok(()) }; @@ -208,6 +209,7 @@ impl Session { library::check_read_only(effective, bdoc, &ds.doc) .and_then(|()| components::check_structure_edit(bdoc, &ds.doc)) .and_then(|()| components::check_override_edit(bdoc, &ds.doc)) + .and_then(|()| components::check_component_cycles(bdoc, &ds.doc)) .map_err(|m| EngineError::Disabled(id.to_string(), m)) } diff --git a/crates/engine/src/sharing/live/events.rs b/crates/engine/src/sharing/live/events.rs index 9d851cab..f738e871 100644 --- a/crates/engine/src/sharing/live/events.rs +++ b/crates/engine/src/sharing/live/events.rs @@ -5,6 +5,12 @@ use super::{ ToolId, VIEW_ONLY, editing_hint, editing_of, sanitize, }; +/// What a live session's document holding components that contain each other's instances shows +/// (`names`: the loop): those instances stop expanding where they would show themselves again. +fn loop_notice(names: &str) -> String { + format!("Components contain each other's instances ({names}): those instances stop where they would show themselves") +} + impl Session { /// The active document (`None`: no document is open). pub(super) fn tab(&self) -> Option { @@ -37,6 +43,12 @@ impl Session { /// Install a document that came from the session (others' edits): the selection, page and view /// stay where they can, and the undo history isn't touched. pub fn apply_remote(&mut self, uid: u64, doc: Arc) { + let Some(old) = self.docs.iter().find(|d| d.uid == uid).map(|d| d.doc.clone()) else { return }; + // Edits merged from others can close a loop of components containing each other. + let changed = crate::derived::changed_nodes(&doc, &old); + if let Some(names) = crate::components::new_cycle(&old, &doc, changed.iter()) { + self.message = Some(loop_notice(&names)); + } let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) else { return }; ds.doc = doc; if !ds.doc.pages.contains(&ds.page) { @@ -355,7 +367,10 @@ impl Session { (Some(Role::Guest), None) => { // The session's file gets a tab of its own, in front. let ds = DocState::new(client.document().clone(), None); - self.message = Some(format!("Joined {}", ds.doc.name)); + self.message = Some(match crate::components::cycle_text(&ds.doc) { + Some(names) => format!("Joined {}. {}", ds.doc.name, loop_notice(&names)), + None => format!("Joined {}", ds.doc.name), + }); let opened = (ds.uid, Some(ds.doc.clone())); self.active = self.docs.len(); self.docs.push(ds); diff --git a/docs/components-parity.md b/docs/components-parity.md index e16fe916..de71082d 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-10-29 · **Change:** minor (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-10-29 · **Change:** minor (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 @@ -18,6 +18,7 @@ accounts). | What it doesn't: position, rotation, constraints and absolute or grid placement of layers inside it, its auto layout direction and other settings, its structure | done | refused with a message, the fields greyed out; the instance itself resizes and its layers follow the main's constraints | | Component sets / variants, add variant, switch | done | Combine as variants is in the Object menu, and the canvas and layers context menu and the Design panel offer it when two or more main components are selected; Add variant on a main component not in a set yet makes it a set of itself and the new variant, one undo step (#903); components that are variants already aren't combined again (it used to put a set inside a set) | | A main's copy within its file is an instance of it: Duplicate, Alt-drag, Copy then Paste | done | a variant duplicated in its set stays a new variant and a whole set copies as a new set; only the copied roots become instances (a main inside a copied frame is copied as a main); Cut then Paste and pastes into other files keep the component (#892) | +| A component can't contain its own instance, directly or through other components | done | refused with the loop named (creating, placing, swapping, switching variants, moving, pasting, an instance-swap value); each variant counts as its own component, so a variant may hold a sibling variant's instance unless that one leads back to it; a dropped instance lands on the page; a file holding a loop opens with one instance detached and a note, any other file unchanged; a loop merged in a live session stops expanding where an instance would show itself again (#1084) | | Switching variants keeps overrides where names match and both variants had the same value; text layers keep theirs | done | | | Default variant: the top-left one (new instances, Assets thumbnail, generated code) | done | | | Variant property order (names rewritten), duplicate combinations reported | done | drag a property in the Properties section to reorder it, one undo step (#679), or `component.editProperty {index}` | diff --git a/docs/roadmap.md b/docs/roadmap.md index 2503af35..df1ab2d4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (`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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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-10-28 · **Change:** minor (M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M2, #1084: a component can't contain its own instance, directly or through other components (each variant counts as its own component, so a variant may hold a sibling variant's instance unless that one leads back to it). Edits that would close such a loop are refused with it named, a file holding one opens with an instance detached and a note, and a rebuild never expands an instance inside a copy of itself (a loop merged in a live session stops growing). Tests: `component_cycle_tests` | | 2026-10-29 | minor | Which copy is which (M8, #995): `edit.duplicate` with `map: true` also returns `map`, each original layer's id, descendants included (nested frames, groups, instances and their sublayers), paired with its copy's id, so a batch can edit inside a copy through a step reference (`"field": "map/6"`) instead of inspecting it. Without `map` the result is unchanged. Regression tests in `crates/engine/src/duplicate_map_tests.rs`. Numbers unchanged | | 2026-10-29 | minor | Bounded replay log (#1069, from a security audit): the batches a session keeps for apps that reconnect were bounded only by count (10,000 batches, 1,000,000 changes) on a desktop host and a private `figcraft-server`, so a long busy session could hold any number of bytes. `Limits::max_log_bytes` now defaults to 64 MiB, and `figcraft-server` takes it from a new `max_log_mb` setting (default 64; open to the public, also at most a quarter of `max_file_mb`, as before). An app further behind than the trimmed log catches up with the whole file, as it already did past the count limits. Nine 8 MiB renames with default limits kept all 75.5 MB before; now 58.7 MB, and a client that missed them gets a snapshot. The protocol is unchanged. | | 2026-10-29 | minor | M8, #998: MCP tools that act on a document (`run_command`, `batch`, `create_node`, `set_properties`, `inspect_document`, `get_node`, `select`, `export`, `screenshot`, `save_file`, `undo`, `redo`) take an optional `document`, an open file's `id` (`doc.list` and the `document.inspect` outline now carry it) or path, and so do the control methods `engine.execute`, `document.inspect`, `node.get` and `ui.render`: the call acts on that file and the tab in front stays in front, so an attached agent keeps editing its own file when the user switches tabs. Calls without it act on the file in front as before; other control methods refuse a `document` behind the tab in front. Numbers unchanged | From 3f896453b66c9cbcaf364d900367a7860772bb46 Mon Sep 17 00:00:00 2001 From: samhyun Date: Thu, 29 Oct 2026 23:08:12 +0900 Subject: [PATCH 34/63] M8: a test for stacked background blurs drawn in bands; the eight-band wording From the review of #1496: three background blurs stacked on each other across the band boundaries draw the same in bands as in one piece (within anti-aliasing), so the bands' overlap, the reach of the largest blur, is enough, as it is for the desktop's parallel bands; pinned by a test. The docs and the `MAX_BANDS` comment no longer put the longest frame at "an eighth of the render": a frame draws at least a band, a part of the render rather than the whole, and the numbers measured for #1496 are cited instead. Co-Authored-By: Claude Fable 5.1 --- crates/render/src/retained_tests.rs | 28 ++++++++++++++++++++++ crates/ui-egui/src/canvas/render_worker.rs | 7 +++--- docs/gaps.md | 5 ++-- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/crates/render/src/retained_tests.rs b/crates/render/src/retained_tests.rs index 89b7b7a1..518033ae 100644 --- a/crates/render/src/retained_tests.rs +++ b/crates/render/src/retained_tests.rs @@ -345,3 +345,31 @@ fn a_reused_or_patched_render_needs_no_steps() { let panned = View { origin: (12.0, 0.0), ..v }; assert!(matches!(r.begin(&s.doc, s.page, &panned, &Options::default(), 40), Some(crate::Begun::Done(_, _, Work::Partial { .. })))); } + +/// Background blurs stacked on each other (the upper reads the lower's blurred pixels) across the +/// band boundaries: the bands' overlap, the reach of the largest blur, is enough for the image to +/// match one drawn in one piece, as it is for the desktop's parallel bands. +#[test] +fn stacked_backdrop_blurs_draw_the_same_in_bands() { + fonts(); + let mut d = Document::new("stacked"); + let page = d.first_page().unwrap(); + // Stripes to blur, then three translucent blurred panels overlapping each other. + for i in 0..40 { + let r = d.create(page, NodeKind::Rectangle, "S", 0.0, f64::from(i) * 10.0, 400.0, 5.0).unwrap(); + solid(d.get_mut(r).unwrap(), if i % 2 == 0 { "E03030" } else { "3070E0" }); + } + for (x, y) in [(40.0, 60.0), (120.0, 120.0), (60.0, 200.0)] { + let b = d.create(page, NodeKind::Rectangle, "B", x, y, 220.0, 160.0).unwrap(); + d.get_mut(b).unwrap().fills = vec![Paint::solid(Color::new(1.0, 1.0, 1.0, 0.25))]; + d.get_mut(b).unwrap().effects.push(Effect { kind: EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(24.0)), visible: true }); + } + let doc = Arc::new(d); + let v = View { origin: (0.0, 0.0), scale: 1.0, width: 400, height: 400 }; + for band_h in [64, 100, 200] { + let mut r = Retained::default(); + let (shown, pix, n) = stepped(&mut r, &doc, page, &v, band_h); + assert!(n >= 3, "{n} bands of {band_h} rows"); + assert_same(&pix, &render_view(&doc, page, &shown, &Options::default()).unwrap(), &format!("bands of {band_h} rows")); + } +} diff --git a/crates/ui-egui/src/canvas/render_worker.rs b/crates/ui-egui/src/canvas/render_worker.rs index 0ffdd34f..3d7659f5 100644 --- a/crates/ui-egui/src/canvas/render_worker.rs +++ b/crates/ui-egui/src/canvas/render_worker.rs @@ -42,9 +42,10 @@ pub const SETTLE_MS: u64 = 120; pub const STEP_MS: f64 = 24.0; /// Bands are at least this many rows ... const MIN_BAND_ROWS: u32 = 64; -/// ... and a view has at most this many: the longest frame of a render drawn in steps is about a -/// band's, an eighth of the render plus its overlap (more bands would repeat more of the overlap -/// and of the per-band work). +/// ... and a view has at most this many: a frame draws at least one band, so the longest frame of +/// a render drawn in steps is about a band's time, a part of the render rather than the whole of it +/// (0.7–1.4 s where the render took 3.7–4.0 s, measured for #1496; more bands would repeat more of +/// the overlap and of the per-band work). const MAX_BANDS: u32 = 8; #[derive(Clone)] diff --git a/docs/gaps.md b/docs/gaps.md index 537b6315..2c04ec5b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -274,8 +274,9 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe page, a zoom after quick renders), a watchdog thread previews any such render still running after 12 ms (desktop only; the browser renders inline). - **Landed (2026-10-29):** the browser draws a zoom's full render a band at a time (`Retained::begin`, - `Steps`; at most eight bands, so the longest frame is about an eighth of the render where it was - the whole of it), so input is handled while it draws: a pan or an edit meanwhile is patched into + `Steps`; at least a band a frame and at most eight bands, so a frame draws a part of the render + where it drew the whole of it: 0.7–1.4 s where the render took 3.7–4.0 s in the measurement of + #1496), so input is handled while it draws: a pan or an edit meanwhile is patched into the render once drawn (or it is drawn again, the first shown till then), a zoom drops it. Bands overlap by what background blurs read, as the desktop's parallel bands do, and the bands together take longer than one render (each repeats its overlap and some per-band work). From 46924f49f4c86729880b0c03cf73c9591d5dea92 Mon Sep 17 00:00:00 2001 From: Carbocode Date: Thu, 29 Oct 2026 15:13:15 +0100 Subject: [PATCH 35/63] M1: address colour picker interaction review findings --- ROADMAP.md | 3 +- crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/app/frame.rs | 25 ++++- .../src/colorpicker_selection_tests.rs | 95 +++++++++++++++++++ crates/ui-egui/src/dialogs/variables_panel.rs | 5 +- crates/ui-egui/src/lib.rs | 6 ++ crates/ui-egui/src/panel_remove_tests.rs | 6 +- crates/ui-egui/src/panels/right.rs | 11 +-- crates/ui-egui/src/panels/right/effects.rs | 4 +- crates/ui-egui/src/panels/right/paints.rs | 22 +---- crates/ui-egui/src/panels/selection_colors.rs | 39 +++++--- crates/ui-egui/src/widgets/colorpicker.rs | 7 +- docs/gaps.md | 3 +- docs/ui-parity.md | 3 +- 14 files changed, 177 insertions(+), 53 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c1a3af30..41466d6d 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-29 · **Change:** minor (colour picker outside-click dismissal and sidebar anchoring; `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-10-29 · **Change:** minor (colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; colour picker outside-click dismissal and sidebar anchoring; `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 @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Colour picker review follow-up: opening the first picker preserves a text undo interaction; placement follows the rendered sidebar; opening controls track their widget ids while moving; linked selection-colour names toggle closed; gradient variable-picker presses keep the parent open. Regression tests cover each case; estimates unchanged. | | 2026-10-29 | minor | Colour pickers dismiss on presses outside their card, including the sidebar, and open 8 pt beside the sidebar aligned to their control (lifted to fit the screen); switching targets resets a dragged position and commits pending edits. Eyedropper sampling stays open. UI regression tests cover added fills/strokes and page colours; headline estimates unchanged. | | 2026-10-29 | minor | Which copy is which (M8, #995): `edit.duplicate` with `map: true` also returns `map`, each original layer's id, descendants included (nested frames, groups, instances and their sublayers), paired with its copy's id, so a batch can edit inside a copy through a step reference (`"field": "map/6"`) instead of inspecting it. Without `map` the result is unchanged. Regression tests in `crates/engine/src/duplicate_map_tests.rs`. Numbers unchanged | | 2026-10-29 | minor | Bounded replay log (#1069, from a security audit): the batches a session keeps for apps that reconnect were bounded only by count (10,000 batches, 1,000,000 changes) on a desktop host and a private `figcraft-server`, so a long busy session could hold any number of bytes. `Limits::max_log_bytes` now defaults to 64 MiB, and `figcraft-server` takes it from a new `max_log_mb` setting (default 64; open to the public, also at most a quarter of `max_file_mb`, as before). An app further behind than the trimmed log catches up with the whole file, as it already did past the count limits. Nine 8 MiB renames with default limits kept all 75.5 MB before; now 58.7 MB, and a client that missed them gets a snapshot. The protocol is unchanged. | diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 44a17658..fcd1b788 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -305,6 +305,7 @@ impl FigApp { layers::left_panel(self, ui) }); } + self.ui.right_panel_rect = None; if right_open { if !narrow { edges.push(("right", ui.available_rect_before_wrap().max.x - right_w, right_w, -1.0)); diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index eff4cfde..128cd029 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -44,11 +44,34 @@ impl FigApp { /// Start a fresh picker beside its control, committing edits to the previous target first. pub fn open_picker(&mut self, target: crate::colorpicker::PickerTarget, button: Option) { - crate::colorpicker::close(self); + if self.ui.picker.is_some() { + crate::colorpicker::close(self); + } + self.ui.picker_popover = None; + self.ui.picker_button_id = None; self.ui.picker = Some(target); self.ui.picker_button = button; } + /// Follow only the widget that opened this target, even when several controls edit it. + pub fn refresh_picker_control(&mut self, target: &crate::colorpicker::PickerTarget, response: &egui::Response) { + if self.ui.picker.as_ref() == Some(target) && self.ui.picker_button_id.is_none_or(|id| id == response.id) { + self.ui.picker_button = response.interact_rect.is_positive().then_some(response.rect); + self.ui.picker_button_id = Some(response.id); + } + } + + /// Drawn colour controls refresh their held rectangle and toggle the picker on a click. + pub fn picker_control(&mut self, target: crate::colorpicker::PickerTarget, response: &egui::Response) { + self.refresh_picker_control(&target, response); + if response.clicked() { + self.toggle_picker(target, Some(response.rect)); + if self.ui.picker.is_some() { + self.ui.picker_button_id = Some(response.id); + } + } + } + /// Leaving Design ends a pen path as Escape does and goes back to Move: Prototype and Inspect /// don't draw (#307). Checked as the frame starts (the control channel) and after the panels /// and shortcuts (the mode switcher, the tabs, Shift+E/Shift+D), before the canvas sees more. diff --git a/crates/ui-egui/src/colorpicker_selection_tests.rs b/crates/ui-egui/src/colorpicker_selection_tests.rs index a115118c..1e0fe8d1 100644 --- a/crates/ui-egui/src/colorpicker_selection_tests.rs +++ b/crates/ui-egui/src/colorpicker_selection_tests.rs @@ -120,3 +120,98 @@ fn wrapped_page_colour_swatches_stay_inset_inside_the_picker() { ); } } + +#[test] +fn opening_the_first_picker_preserves_the_active_text_undo_interaction() { + let mut s = Session::new(); + let text = NodeId(s.execute("text.create", &json!({"text": "Hi"})).unwrap()["id"].as_u64().unwrap()); + let mut app = crate::FigApp::new(s, Box::new(crate::NoServices)); + figcraft_engine::textedit::begin(&mut app.session, text, Some(2), false).unwrap(); + figcraft_engine::textedit::insert(&mut app.session, "a").unwrap(); + let depth = app.session.undo_depth(); + app.open_picker(PickerTarget::TextRange { id: text, start: 0, end: 2 }, None); + assert!(app.session.ds().unwrap().interaction.is_some()); + assert_eq!(app.session.undo_depth(), depth); + figcraft_engine::textedit::insert(&mut app.session, "b").unwrap(); + figcraft_engine::textedit::end(&mut app.session).unwrap(); + app.session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().node(text).unwrap().kind.text().unwrap().characters, "Hi"); +} + +#[test] +fn the_picker_uses_the_fitted_sidebar_edge() { + use egui::{Pos2, RawInput, Rect, vec2}; + let (mut u, a, _) = two(); + u.app.ui.right_width = 480.0; + u.app.open_picker(PickerTarget::Paint { fills: true, index: 0, ids: vec![a] }, None); + for _ in 0..6 { + u.frame_raw(RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 900.0))), ..Default::default() }); + } + let sidebar = u.app.ui.right_panel_rect.unwrap(); + assert!(sidebar.width() < u.app.ui.right_width, "the requested width does not fit"); + let pop = u.app.ui.picker_popover.as_ref().unwrap(); + assert!((pop.rect.max.x - (sidebar.min.x - 8.0)).abs() < 1.0, "{:?} beside {sidebar:?}", pop.rect); +} + +#[test] +fn moved_page_effect_and_text_range_swatches_still_toggle_the_picker_closed() { + use egui::{Id, Pos2}; + for kind in ["page", "effect", "text"] { + let (mut u, a, _) = two(); + let target = match kind { + "page" => { + u.app.session.execute("select.none", &json!({})).unwrap(); + PickerTarget::PageBackground + } + "effect" => { + u.app.session.execute("effect.add", &json!({"id":a,"type":"dropShadow"})).unwrap(); + u.ctx.data_mut(|d| d.insert_temp(Id::unique(("fx_open", 0usize)), true)); + PickerTarget::Effect { ids: vec![a], index: 0, secondary: false } + } + _ => { + let text = NodeId(u.app.session.execute("text.create", &json!({"text":"Hello"})).unwrap()["id"].as_u64().unwrap()); + figcraft_engine::textedit::begin(&mut u.app.session, text, None, false).unwrap(); + PickerTarget::TextRange { id: text, start: 0, end: 5 } + } + }; + u.app.open_picker(target, None); + for _ in 0..5 { + u.frame(vec![]); + } + let before = u.app.ui.picker_button.expect(kind); + u.app.ui.right_width = 360.0; + for _ in 0..5 { + u.frame(vec![]); + } + let after = u.app.ui.picker_button.expect(kind); + assert!((after.min.x - before.min.x).abs() > 50.0, "{kind}: swatch moves"); + u.button(after.center(), true); + u.button(after.center(), false); + u.frame(vec![egui::Event::PointerMoved(Pos2::new(1430.0, 890.0))]); + assert!(u.app.ui.picker.is_none(), "{kind}: the moved swatch closes it"); + } +} + +#[test] +fn a_press_in_the_gradient_variable_picker_keeps_the_colour_picker_open() { + use egui::{Pos2, RawInput, Rect, pos2, vec2}; + let (mut u, a, _) = two(); + u.app.session.execute("fill.set", &json!({"id":a,"paint":{"type":"linearGradient","stops":["#ff0000","#0000ff"]}})).unwrap(); + u.app.open_picker(PickerTarget::Paint { fills: true, index: 0, ids: vec![a] }, None); + for _ in 0..5 { + u.frame(vec![]); + } + // Open the same child picker used by a gradient stop, away from the parent's card. + let _ = u.ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), ..Default::default() }, |ui| { + crate::variable_ui::open(ui, &[a], "fills/0/stops/0/color", figcraft_doc::VariableType::Color, pos2(500.0, 180.0)); + }); + for _ in 0..5 { + u.frame(vec![]); + } + let child = crate::variable_ui::picker_rect(&u.ctx).unwrap(); + let at = child.min + vec2(30.0, 20.0); + assert!(!u.app.ui.picker_popover.as_ref().unwrap().rect.contains(at)); + u.button(at, true); + assert!(u.app.ui.picker.is_some(), "the child variable picker is part of the interaction"); + u.button(at, false); +} diff --git a/crates/ui-egui/src/dialogs/variables_panel.rs b/crates/ui-egui/src/dialogs/variables_panel.rs index 93d567a8..710c0db5 100644 --- a/crates/ui-egui/src/dialogs/variables_panel.rs +++ b/crates/ui-egui/src/dialogs/variables_panel.rs @@ -839,9 +839,8 @@ fn value_cell(app: &mut FigApp, ui: &mut Ui, view: &View, v: &Variable, coll: &C ui.painter().rect_filled(body, CornerRadius::same(5), t.field); let sw = Rect::from_center_size(pos2(body.min.x + 13.0, body.center().y), vec2(14.0, 14.0)); widgets::paint_swatch(ui.painter(), sw, &figcraft_color::Paint::solid(*c)); - if ui.interact(sw.expand(3.0), id.with("swatch"), Sense::click()).tip(tr("Edit colour")).clicked() { - app.toggle_picker(PickerTarget::Variable { id: v.id.clone(), mode: mode.to_string() }, Some(sw.expand(3.0))); - } + let response = ui.interact(sw.expand(3.0), id.with("swatch"), Sense::click()).tip(tr("Edit colour")); + app.picker_control(PickerTarget::Variable { id: v.id.clone(), mode: mode.to_string() }, &response); let hex_r = Rect::from_min_max(pos2(sw.max.x + 8.0, body.min.y + 4.0), pos2(body.max.x - 44.0, body.max.y - 4.0)); if let Some(h) = widgets::text_field_inline(ui, id.with("hex"), &c.to_hex(), hex_r, regular(11.0)) && let Some(nc) = figcraft_color::Color::from_hex_or_name(&h) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index cef6515b..b272ffb3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -345,6 +345,8 @@ pub struct UiState { /// small window draws). pub left_width: f32, pub right_width: f32, + /// The right sidebar as drawn this frame, after fitting the window. + pub right_panel_rect: Option, pub right_tab: RightTab, /// The tab of the previous frame, to see the right panel change mode. pub last_right_tab: RightTab, @@ -422,6 +424,8 @@ pub struct UiState { /// The control that opened the picker, refreshed every frame it is drawn (the panel scrolls): /// a press in it is held out of the popover's closing, so its own click toggles the picker. pub picker_button: Option, + /// The opening widget, so only that control refreshes the held rectangle as it moves. + pub picker_button_id: Option, pub context_menu: Option<(egui::Pos2, Vec)>, /// The context menu was asked for from the keyboard (`view.contextMenu`): it opens at the /// focused layer row or the canvas selection and takes the keyboard. @@ -558,6 +562,7 @@ impl Default for UiState { Self { left_width: PANEL_DEFAULT, right_width: PANEL_DEFAULT, + right_panel_rect: None, right_tab: RightTab::Design, last_right_tab: RightTab::Design, left_tab: LeftTab::File, @@ -603,6 +608,7 @@ impl Default for UiState { crop: None, picker_popover: None, picker_button: None, + picker_button_id: None, context_menu: None, key_menu: false, key_box: None, diff --git a/crates/ui-egui/src/panel_remove_tests.rs b/crates/ui-egui/src/panel_remove_tests.rs index 98ddc4d1..ac1afdd1 100644 --- a/crates/ui-egui/src/panel_remove_tests.rs +++ b/crates/ui-egui/src/panel_remove_tests.rs @@ -241,7 +241,7 @@ fn added_paints_open_beside_the_sidebar_and_close_on_a_sidebar_press() { } assert!(matches!(u.app.ui.picker, Some(PickerTarget::Paint { fills: f, .. }) if f == fills)); let pop = u.app.ui.picker_popover.as_ref().unwrap(); - let sidebar_left = 1440.0 - u.app.ui.right_width; + let sidebar_left = u.app.ui.right_panel_rect.unwrap().min.x; assert!((pop.rect.max.x - (sidebar_left - 8.0)).abs() < 1.0, "{:?}", pop.rect); assert!(pop.rect.min.y <= add.y, "the picker aligns with its opener or lifts to fit"); assert!(pop.rect.max.y <= 900.0 - 8.0); @@ -269,6 +269,6 @@ fn page_colour_picker_closes_on_an_outside_press_and_reopens_at_its_control() { u.frame(vec![]); } let rect = u.app.ui.picker_popover.as_ref().unwrap().rect; - assert!((rect.max.x - (1440.0 - u.app.ui.right_width - 8.0)).abs() < 1.0); - assert!((rect.min.y - button.min.y).abs() < 1.0); + assert!((rect.max.x - (u.app.ui.right_panel_rect.unwrap().min.x - 8.0)).abs() < 1.0); + assert!((rect.min.y - u.app.ui.picker_button.unwrap().min.y).abs() < 1.0); } diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index f5ddeffd..f4fd453e 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -108,6 +108,7 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { // The panel's edge, drawn here: the frame has no stroke (a stroke counts as margin and would // take 1 pt off each side of the panel (240 pt by default)). let edge = ui.max_rect(); + app.ui.right_panel_rect = Some(edge); ui.painter().line_segment([edge.left_top(), edge.left_bottom()], Stroke::new(1.0, t.border)); let w = ui.available_width(); // Top bar: people (you and the others in a live session), present, share. @@ -309,24 +310,16 @@ fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) { _ => figcraft_color::Color::DEFAULT_PAGE, }; widgets::section_header(ui, tr("Page"), &[], false); - let mut open_picker = false; - let mut swatch_top = None; let mut new_hex = None; row(ui, 32.0, |ui| { let p = Paint::solid(bg); let sw = widgets::swatch(ui, &p, 16.0); - if sw.clicked() { - open_picker = true; - swatch_top = Some(sw.rect); - } + app.picker_control(crate::colorpicker::PickerTarget::PageBackground, &sw); if let Some(h) = widgets::text_field(ui, Id::unique("pagebg"), &bg.to_hex(), 96.0, "") { new_hex = Some(h); } widgets::label(ui, "100 %", t.text); }); - if open_picker { - app.toggle_picker(crate::colorpicker::PickerTarget::PageBackground, swatch_top); - } if let Some(h) = new_hex && let Some(c) = figcraft_color::Color::from_hex_or_name(&h) { diff --git a/crates/ui-egui/src/panels/right/effects.rs b/crates/ui-egui/src/panels/right/effects.rs index 578bd164..528d19bf 100644 --- a/crates/ui-egui/src/panels/right/effects.rs +++ b/crates/ui-egui/src/panels/right/effects.rs @@ -334,9 +334,7 @@ fn tweaked(e: &Effect, f: impl FnOnce(&mut EffectKind)) -> Effect { fn fx_color(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Effect, secondary: bool, edit: &ColorEdit<'_>) { let Some(c) = e.color(secondary) else { return }; let sw = widgets::swatch(ui, &Paint::solid(c), 16.0); - if sw.clicked() { - app.toggle_picker(crate::colorpicker::PickerTarget::Effect { ids: ids.to_vec(), index: i, secondary }, Some(sw.rect)); - } + app.picker_control(crate::colorpicker::PickerTarget::Effect { ids: ids.to_vec(), index: i, secondary }, &sw); if let Some(h) = widgets::text_field(ui, Id::unique(("fx_c", i, secondary)), &c.to_hex(), 80.0, "") && let Some(nc) = figcraft_color::Color::from_hex_or_name(&h) { diff --git a/crates/ui-egui/src/panels/right/paints.rs b/crates/ui-egui/src/panels/right/paints.rs index ce99ceb7..28af83b7 100644 --- a/crates/ui-egui/src/panels/right/paints.rs +++ b/crates/ui-egui/src/panels/right/paints.rs @@ -254,9 +254,7 @@ fn range_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId, start: Some(p) => { widgets::paint_swatch(ui.painter(), sw, p); let range_swatch = ui.interact(sw.expand(2.0), Id::unique("range_swatch"), Sense::click()); - if range_swatch.clicked() { - app.toggle_picker(crate::colorpicker::PickerTarget::TextRange { id, start, end }, Some(range_swatch.rect)); - } + app.picker_control(crate::colorpicker::PickerTarget::TextRange { id, start, end }, &range_swatch); if let Some(c) = p.solid_color() { let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_r)); if let Some(h) = @@ -271,9 +269,8 @@ fn range_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId, start: } None => { ui.painter().text(pos2(text_r.min.x, r.center().y), Align2::LEFT_CENTER, tr("Mixed"), regular(11.0), t.text_secondary); - if ui.interact(r, Id::unique("range_mixed"), Sense::click()).clicked() { - app.open_picker(crate::colorpicker::PickerTarget::TextRange { id, start, end }, Some(r)); - } + let response = ui.interact(r, Id::unique("range_mixed"), Sense::click()); + app.picker_control(crate::colorpicker::PickerTarget::TextRange { id, start, end }, &response); } } // Back to the layer's fill. @@ -454,18 +451,7 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint, let sw = Rect::from_min_size(pos2(r.min.x + 4.0, r.center().y - 8.0), vec2(16.0, 16.0)); widgets::paint_swatch(ui.painter(), sw, p); let sresp = ui.interact(sw.expand(2.0), Id::unique(("swatch", fills, i)), Sense::click()); - // While this paint is the one the picker edits, its swatch is held out of the popover's - // closing, so a press on it again reaches the toggle below. - let editing = matches!( - &app.ui.picker, - Some(crate::colorpicker::PickerTarget::Paint { fills: f, index: k, ids: held }) if *f == fills && *k == i && *held == ids - ); - if editing { - app.ui.picker_button = Some(sresp.rect); - } - if sresp.clicked() { - app.toggle_picker(crate::colorpicker::PickerTarget::Paint { fills, index: i, ids: ids.clone() }, Some(sresp.rect)); - } + app.picker_control(crate::colorpicker::PickerTarget::Paint { fills, index: i, ids: ids.clone() }, &sresp); let text_r = Rect::from_min_max(pos2(sw.max.x + 8.0, r.min.y), pos2(r.max.x - 52.0, r.max.y)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_r)); // The variable bound to this paint's colour (its key on the layers: `fill` or `fills//color`). diff --git a/crates/ui-egui/src/panels/selection_colors.rs b/crates/ui-egui/src/panels/selection_colors.rs index 384db7c0..9d7a058c 100644 --- a/crates/ui-egui/src/panels/selection_colors.rs +++ b/crates/ui-egui/src/panels/selection_colors.rs @@ -47,9 +47,14 @@ fn colors(ui: &Ui, document: u64, doc: &Arc, ids: &[NodeId], interacti colors } -pub(crate) fn toggle(app: &mut FigApp, targets: Vec, top: Option) { +pub(crate) fn toggle(app: &mut FigApp, targets: Vec, response: Option<&egui::Response>) { let Ok(ds) = app.session.ds() else { return }; - app.toggle_picker(PickerTarget::SelectionColor { targets, selection: ds.selection.clone(), document: ds.uid }, top); + let target = PickerTarget::SelectionColor { targets, selection: ds.selection.clone(), document: ds.uid }; + if let Some(response) = response { + app.picker_control(target, response); + } else { + app.toggle_picker(target, None); + } } /// Draw the selection-colour groups (at least two): swatch opens the picker, hex and opacity @@ -71,9 +76,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, ids: &[NodeId ui.push_id((&group.targets, "selection_color"), |ui| { crate::right::row(ui, 32.0, |ui| { let swatch = widgets::swatch(ui, &group.paint, 24.0).tip(tr("Edit colour")); - if swatch.clicked() { - toggle(app, group.targets.clone(), Some(swatch.rect)); - } + toggle(app, group.targets.clone(), Some(&swatch)); let width = (ui.available_width() - 116.0).max(40.0); let linked_name = group .style @@ -81,9 +84,8 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, ids: &[NodeId .and_then(|id| doc.style(id).map(|s| s.name.as_str())) .or_else(|| group.variable.as_ref().and_then(|id| doc.variables.get(id).map(|v| v.name.as_str()))); if let Some(name) = linked_name { - if widgets::dropdown(ui, name, width, None).clicked() { - toggle(app, group.targets.clone(), None); - } + let response = widgets::dropdown(ui, name, width, None); + toggle(app, group.targets.clone(), Some(&response)); } else if let Some(color) = group.paint.solid_color() { if let Some(hex) = widgets::text_field_named(ui, ui.scope_id().with("hex"), &color.to_hex(), width, tr("Color")) { if let Some(color) = Color::from_hex(&hex) { @@ -99,9 +101,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, ids: &[NodeId } } else { let response = widgets::dropdown(ui, &widgets::paint_name(&group.paint), width, None); - if response.clicked() { - toggle(app, group.targets.clone(), Some(response.rect)); - } + toggle(app, group.targets.clone(), Some(&response)); } let alpha = format!("{}%", figcraft_doc::trim_num(f64::from(group.paint.opacity) * 100.0)); if let Some(value) = widgets::text_field_named(ui, ui.scope_id().with("opacity"), &alpha, 44.0, tr("Opacity")) @@ -312,6 +312,23 @@ mod tests { assert_eq!(colors.len(), 2); } + #[test] + fn a_linked_name_control_tracks_itself_and_toggles_closed() { + let (mut p, a, _) = Panel::new(); + p.app.run("style.create", json!({"type":"paint","name":"Brand","value":"#ff0000"})); + p.app.run("style.apply", json!({"id":a,"style":"Brand","target":"fill"})); + let name = p.frame(vec![]).into_iter().find(|(s, _)| s == "Brand").unwrap().1; + p.click(name); + assert!(p.app.ui.picker.is_some()); + let opening = p.app.ui.picker_button.unwrap(); + let opening_id = p.app.ui.picker_button_id.unwrap(); + p.frame(vec![]); + assert_eq!(p.app.ui.picker_button_id, Some(opening_id), "the swatch must not replace the name control"); + assert_eq!(p.app.ui.picker_button, Some(opening)); + p.click(name); + assert!(p.app.ui.picker.is_none()); + } + #[test] fn styles_menu_applies_a_color_variable_to_the_row() { let (mut p, a, b) = Panel::new(); diff --git a/crates/ui-egui/src/widgets/colorpicker.rs b/crates/ui-egui/src/widgets/colorpicker.rs index f8b43d74..99af5f0f 100644 --- a/crates/ui-egui/src/widgets/colorpicker.rs +++ b/crates/ui-egui/src/widgets/colorpicker.rs @@ -866,7 +866,8 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { }; let open = beside.map(Open::At).unwrap_or_else(|| { let top = app.ui.picker_button.map_or(140.0, |r| r.min.y); - Open::Anchor(pos2(screen.max.x - app.ui.right_width - 8.0, top)) + let edge = app.ui.right_panel_rect.map_or(screen.max.x - app.ui.right_width, |r| r.min.x); + Open::Anchor(pos2(edge - 8.0, top)) }); // The colour picker's own header — title 16 in, the X 8 in from the right — is the shared // popover's chrome now; the picker keeps its body. Its area id is [`window_id`], the one the @@ -877,7 +878,8 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { let picking = app.session.tool.pick_only || app.session.tool.picked.is_some(); pop = pop.close_on_outside(!picking); // Only the opening control is held out: any other sidebar or canvas press dismisses it. - let held: Vec = app.ui.picker_button.into_iter().collect(); + let mut held: Vec = app.ui.picker_button.into_iter().collect(); + held.extend(crate::variable_ui::picker_rect(ctx)); let closed = pop.show(ctx, open, Hold { rects: &held, escape: false }, |ui| { ui.spacing_mut().item_spacing = vec2(8.0, 8.0); // The eyedropper's colour goes to this picker's paint (a stroke, an effect, a gradient stop...). @@ -1042,6 +1044,7 @@ pub fn close(app: &mut FigApp) { app.ui.picker = None; app.ui.picker_popover = None; app.ui.picker_button = None; + app.ui.picker_button_id = None; } /// Swatches wrapped in rows inset 16 points from both sides of the picker. diff --git a/docs/gaps.md b/docs/gaps.md index 50c8a050..692ecf72 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (colour picker outside-click dismissal and sidebar anchoring; `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); Arabic draft catalog; gap 7: the browser refines a zoom's preview once the zoom has paused; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; colour picker outside-click dismissal and sidebar anchoring; `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); Arabic draft catalog; gap 7: the browser refines a zoom's preview once the zoom has paused; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -555,6 +555,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Colour picker review follow-up: opening the first picker preserves a text undo interaction; placement follows the rendered sidebar; opening controls track their widget ids while moving; linked selection-colour names toggle closed; gradient variable-picker presses keep the parent open. Regression tests cover each case; estimates unchanged. | | 2026-10-29 | minor | Colour pickers dismiss on presses outside their card, including the sidebar, and open 8 pt beside the sidebar aligned to their control (lifted to fit the screen); switching targets resets a dragged position and commits pending edits. Eyedropper sampling stays open. UI regression tests cover added fills/strokes and page colours; headline estimates unchanged. | | 2026-10-28 | minor | Arabic draft: 1,857 UI strings, 620 command labels, 31 menu wordings; count-neutral plurals and web font gate. Native review and panel mirroring remain open; grade and estimates unchanged. Evidence: `locales/ar.toml`, i18n catalog tests and [translation notes](translating.md#arabic-draft) | | 2026-10-28 | minor | Gap 7: the browser refines a zoom's preview only once the zoom has paused for 120 ms, so a wheel zoom no longer runs a blocking full render between every two steps. Numbers unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 6f86a84f..c53d1bf5 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-10-29 · **Change:** minor (colour picker outside-click dismissal and sidebar anchoring; 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; 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; 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; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; 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) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; colour picker outside-click dismissal and sidebar anchoring; 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; 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; 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; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; 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) · **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: @@ -175,6 +175,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Colour picker review follow-up: opening the first picker preserves a text undo interaction; placement follows the rendered sidebar; opening controls track their widget ids while moving; linked selection-colour names toggle closed; gradient variable-picker presses keep the parent open. Regression tests cover each case; estimates unchanged. | | 2026-10-29 | minor | Colour pickers dismiss on presses outside their card, including the sidebar, and open 8 pt beside the sidebar aligned to their control (lifted to fit the screen); switching targets resets a dragged position and commits pending edits. Eyedropper sampling stays open. UI regression tests cover added fills/strokes and page colours; headline estimates unchanged. | | 2026-10-29 | minor | Crop image (#1464): Object ▸ Crop image, the right-click menu and a crop button on the image fill row enter crop mode on the canvas (`image.crop`; the image dimmed outside the layer, corner brackets and edge bars on its box; Enter, Escape or a click outside finishes). The row stays done | | 2026-10-29 | minor | Toolbar (#1210): every state of the pill (tools, vector edit, shape builder, view only) is laid out on one 32 pt cell, 8 pt in from its ends and 8 pt between its items, with a 1 pt divider and 14 pt mode-switcher marks; the Resources button is gone, as in UI3 (the left rail's Assets tab, Shift+I, Alt+2 and View ▸ Panels ▸ Assets open the Assets panel); a narrow window still scrolls the pill (#1209) (`resources_tests`, `toolbar` tests). Numbers unchanged | From f781abe5f555da490c6868e83599e7b186b1c900 Mon Sep 17 00:00:00 2001 From: Carbocode Date: Thu, 29 Oct 2026 15:22:29 +0100 Subject: [PATCH 36/63] M2: preserve masks and sizing when adding auto layout to groups --- ROADMAP.md | 3 +- crates/engine/src/cmd/structure.rs | 49 +++++++--- crates/engine/src/convert_container_tests.rs | 99 ++++++++++++++++++++ crates/ui-egui/src/field_tests.rs | 27 ++++++ crates/ui-egui/src/panels/layout_panel.rs | 5 +- docs/auto-layout-parity.md | 9 +- 6 files changed, 176 insertions(+), 16 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e9c2e032..26ab4e04 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-29 · **Change:** minor (Layout sidebar: groups expose Add auto layout and convert to frames in place; `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-10-29 · **Change:** minor (Layout sidebar: groups expose Add auto layout and convert to frames in place, preserving masks, flow spacing and Fill sizing; `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 @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M2 review fixes: adding auto layout preserves mask run order and a converted group’s Fill sizing; direction, gap and padding are inferred only from visible, non-absolute children. The sidebar hides Add when any selected container already has auto layout. Render, layout and sidebar regressions cover the review findings. Numbers unchanged | | 2026-10-29 | minor | M2: the Design sidebar’s Layout header shows Add auto layout (+) for groups as well as frames. `layout.add` converts a single selected group into a frame in place, retaining its id, name and direct children, so auto layout arranges those children instead of treating the whole group as one fixed item inside a new wrapper. This matches the Figma workflow reported by the contributor and the sidebar workflow familiar to users of similar design tools. Sidebar, context menu, shortcut and programmatic callers share the fix; conversion and layout undo together. Regression tests in `convert_container_tests`. Numbers unchanged | | 2026-10-29 | minor | Which copy is which (M8, #995): `edit.duplicate` with `map: true` also returns `map`, each original layer's id, descendants included (nested frames, groups, instances and their sublayers), paired with its copy's id, so a batch can edit inside a copy through a step reference (`"field": "map/6"`) instead of inspecting it. Without `map` the result is unchanged. Regression tests in `crates/engine/src/duplicate_map_tests.rs`. Numbers unchanged | | 2026-10-29 | minor | Bounded replay log (#1069, from a security audit): the batches a session keeps for apps that reconnect were bounded only by count (10,000 batches, 1,000,000 changes) on a desktop host and a private `figcraft-server`, so a long busy session could hold any number of bytes. `Limits::max_log_bytes` now defaults to 64 MiB, and `figcraft-server` takes it from a new `max_log_mb` setting (default 64; open to the public, also at most a quarter of `max_file_mb`, as before). An app further behind than the trimmed log catches up with the whole file, as it already did past the count limits. Nine 8 MiB renames with default limits kept all 75.5 MB before; now 58.7 MB, and a client that missed them gets a snapshot. The protocol is unchanged. | diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs index ab7f6544..b99ba406 100644 --- a/crates/engine/src/cmd/structure.rs +++ b/crates/engine/src/cmd/structure.rs @@ -587,11 +587,14 @@ fn outline_stroke(s: &mut Session, p: &Value) -> Result { Ok(json!({"ids": out})) } -/// Infer direction, gap and padding from where children are (Figma's "Add auto layout"). +/// Infer direction, gap and padding from visible, non-absolute children, matching the layout flow. fn infer_layout(doc: &Document, frame: NodeId) -> AutoLayout { let Some(f) = doc.node(frame) else { return AutoLayout::default() }; - let boxes: Vec = - f.children.iter().filter_map(|c| doc.node(*c)).map(|c| figcraft_geom::transform_rect_bbox(c.transform, c.local_rect())).collect(); + let boxes: Vec = figcraft_layout::flow_children(doc, frame) + .iter() + .filter_map(|c| doc.node(*c)) + .map(|c| figcraft_geom::transform_rect_bbox(c.transform, c.local_rect())) + .collect(); if boxes.is_empty() { return AutoLayout { padding: [10.0; 4], gap: 10.0, ..Default::default() }; } @@ -608,6 +611,30 @@ fn infer_layout(doc: &Document, frame: NodeId) -> AutoLayout { AutoLayout { mode: if vertical { LayoutMode::Vertical } else { LayoutMode::Horizontal }, gap, padding: pad, ..Default::default() } } +/// Sort unmasked spans by position along the layout axis. Masks stay at their original indices; +/// each visible mask's following content keeps its paint order until the next mask, hidden or not. +fn sort_layout_children(doc: &Document, kids: &mut [NodeId], vertical: bool) { + let mut start = 0; + let mut masked = false; + for end in 0..=kids.len() { + let mask = kids.get(end).and_then(|id| doc.node(*id)).filter(|n| n.is_mask); + if end != kids.len() && mask.is_none() { + continue; + } + if !masked && let Some(span) = kids.get_mut(start..end) { + span.sort_by(|a, b| { + let pa = doc.node(*a).map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect())).unwrap_or_default(); + let pb = doc.node(*b).map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect())).unwrap_or_default(); + if vertical { pa.y0.total_cmp(&pb.y0) } else { pa.x0.total_cmp(&pb.x0) } + }); + } + start = end + 1; + masked = mask.is_some_and(|n| n.visible); + } +} + +/// Add inferred auto layout to existing frames or one group converted in place; wrap other +/// selections. Preserve mask boundaries and a converted group's Fill sizing in its parent. fn layout_add(s: &mut Session, p: &Value) -> Result { let ids = req_targets("layout.add", s, p)?; let dir = string(p, "direction"); @@ -641,14 +668,8 @@ fn layout_add(s: &mut Session, p: &Value) -> Result { _ => LayoutMode::Horizontal, }; } - // Children in reading order along the axis. let mut kids = doc.get(*f)?.children.clone(); - let vertical = al.mode == LayoutMode::Vertical; - kids.sort_by(|a, b| { - let pa = doc.node(*a).map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect())).unwrap_or_default(); - let pb = doc.node(*b).map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect())).unwrap_or_default(); - if vertical { pa.y0.total_cmp(&pb.y0) } else { pa.x0.total_cmp(&pb.x0) } - }); + sort_layout_children(doc, &mut kids, al.mode == LayoutMode::Vertical); let empty = doc.node(*f).is_some_and(|n| n.children.iter().filter_map(|c| doc.node(*c)).all(|c| !c.visible || c.child_layout.absolute)); let n = doc.get_mut(*f)?; n.children = kids; @@ -656,8 +677,12 @@ fn layout_add(s: &mut Session, p: &Value) -> Result { if !in_place || !empty { // A frame wrapped around the selection hugs it; an existing frame with no flow // children keeps its size instead of collapsing to its padding. - n.child_layout.sizing_h = Sizing::Hug; - n.child_layout.sizing_v = Sizing::Hug; + if !single_group || n.child_layout.sizing_h != Sizing::Fill { + n.child_layout.sizing_h = Sizing::Hug; + } + if !single_group || n.child_layout.sizing_v != Sizing::Fill { + n.child_layout.sizing_v = Sizing::Hug; + } } } s.set_selection(targets.clone()); diff --git a/crates/engine/src/convert_container_tests.rs b/crates/engine/src/convert_container_tests.rs index 5bc55638..db128293 100644 --- a/crates/engine/src/convert_container_tests.rs +++ b/crates/engine/src/convert_container_tests.rs @@ -79,6 +79,105 @@ fn adding_auto_layout_to_multiple_groups_still_wraps_the_selection() { assert!(matches!(kind(&s, g1), NodeKind::Group) && matches!(kind(&s, g2), NodeKind::Group)); } +#[test] +fn adding_auto_layout_preserves_mask_runs_and_their_rendering() { + let mut s = Session::new(); + let page = s.page().unwrap(); + let (right, left) = (rect(&mut s, page, 300.0, 0.0), rect(&mut s, page, 0.0, 0.0)); + let (mask, content) = (rect(&mut s, page, 100.0, 0.0), rect(&mut s, page, 80.0, 0.0)); + let (mask2, content2) = (rect(&mut s, page, 200.0, 0.0), rect(&mut s, page, 180.0, 0.0)); + let group = id(ex(&mut s, "object.group", json!({"ids": [right, left, mask, content, mask2, content2]}))); + ex(&mut s, "node.set", json!({"ids": [mask, mask2], "props": {"isMask": true}})); + // Absolute layers keep the overlaps, isolating paint order from normal flow placement. + ex(&mut s, "node.set", json!({"ids": [mask, content, mask2, content2], "props": {"absolute": true}})); + let before = figcraft_render::render_node(s.doc().unwrap(), group, 1.0).unwrap(); + ex(&mut s, "layout.add", json!({"ids": [group]})); + let doc = s.doc().unwrap(); + let frame = doc.node(group).unwrap(); + assert_eq!(frame.children, vec![left, right, mask, content, mask2, content2], "sort the unmasked prefix, retain mask runs"); + let runs: Vec> = doc.paint_runs(frame).iter().map(|r| frame.children[r.clone()].to_vec()).collect(); + assert_eq!(runs, vec![vec![left], vec![right], vec![mask, content], vec![mask2, content2]]); + let after = figcraft_render::render_node(doc, group, 1.0).unwrap(); + assert_eq!((after.width(), after.height(), after.data()), (before.width(), before.height(), before.data())); + s.undo().unwrap(); + assert_eq!(s.doc().unwrap().node(group).unwrap().children, vec![right, left, mask, content, mask2, content2]); +} + +#[test] +fn a_hidden_mask_keeps_its_boundary_but_unmasked_children_still_sort() { + let mut s = Session::new(); + let page = s.page().unwrap(); + let mask = rect(&mut s, page, 100.0, 0.0); + let content = rect(&mut s, page, 80.0, 0.0); + let hidden_mask = rect(&mut s, page, 200.0, 0.0); + let (right, left) = (rect(&mut s, page, 300.0, 0.0), rect(&mut s, page, 250.0, 0.0)); + let group = id(ex(&mut s, "object.group", json!({"ids": [mask, content, hidden_mask, right, left]}))); + ex(&mut s, "node.set", json!({"ids": [mask, hidden_mask], "props": {"isMask": true}})); + ex(&mut s, "node.set", json!({"id": hidden_mask, "props": {"visible": false}})); + ex(&mut s, "layout.add", json!({"ids": [group], "direction": "horizontal"})); + let doc = s.doc().unwrap(); + let frame = doc.node(group).unwrap(); + assert_eq!(frame.children, vec![mask, content, hidden_mask, left, right]); + let runs: Vec> = doc.paint_runs(frame).iter().map(|r| frame.children[r.clone()].to_vec()).collect(); + assert_eq!(runs, vec![vec![mask, content], vec![hidden_mask], vec![left], vec![right]]); +} + +#[test] +fn adding_auto_layout_infers_gaps_from_flow_children_only() { + for props in [json!({"visible": false}), json!({"absolute": true})] { + let mut s = Session::new(); + let page = s.page().unwrap(); + let a = rect(&mut s, page, 0.0, 0.0); + let excluded = rect(&mut s, page, 80.0, 0.0); + let b = rect(&mut s, page, 160.0, 0.0); + let group = id(ex(&mut s, "object.group", json!({"ids": [a, excluded, b]}))); + ex(&mut s, "node.set", json!({"id": excluded, "props": props})); + let before = [page_box(&s, a), page_box(&s, b)]; + ex(&mut s, "layout.add", json!({"ids": [group]})); + let layout = s.doc().unwrap().node(group).unwrap().layout.as_ref().unwrap(); + assert_eq!(layout.gap, 120.0); + assert_eq!([page_box(&s, a), page_box(&s, b)], before); + } +} + +#[test] +fn hidden_children_do_not_influence_inferred_direction_or_padding() { + let mut s = Session::new(); + let frame = id(ex(&mut s, "frame.create", json!({"width": 300, "height": 200}))); + let (a, b) = (rect(&mut s, frame, 10.0, 20.0), rect(&mut s, frame, 90.0, 20.0)); + let hidden = rect(&mut s, frame, 0.0, 1000.0); + ex(&mut s, "node.set", json!({"id": hidden, "props": {"visible": false}})); + let before = [page_box(&s, a), page_box(&s, b)]; + ex(&mut s, "layout.add", json!({"ids": [frame]})); + let layout = s.doc().unwrap().node(frame).unwrap().layout.as_ref().unwrap(); + assert_eq!(layout.mode, figcraft_doc::LayoutMode::Horizontal); + assert_eq!(layout.padding, [20.0, 170.0, 160.0, 10.0]); + assert_eq!(layout.gap, 40.0); + assert_eq!([page_box(&s, a), page_box(&s, b)], before); +} + +#[test] +fn converting_a_group_preserves_fill_on_each_axis_in_its_parent() { + for (h, v) in [("fill", "fixed"), ("fixed", "fill"), ("fill", "fill")] { + let mut s = Session::new(); + let parent = id(ex(&mut s, "frame.create", json!({"width": 500, "height": 300}))); + let (a, b) = (rect(&mut s, parent, 0.0, 0.0), rect(&mut s, parent, 80.0, 0.0)); + let group = id(ex(&mut s, "object.group", json!({"ids": [a, b]}))); + let sibling = rect(&mut s, parent, 200.0, 0.0); + ex(&mut s, "node.set", json!({"id": parent, "props": {"layout": "horizontal", "padding": 0, "gap": 10}})); + ex(&mut s, "node.set", json!({"id": group, "props": {"sizingH": h, "sizingV": v}})); + let before = [page_box(&s, group), page_box(&s, sibling)]; + ex(&mut s, "layout.add", json!({"ids": [group]})); + let cl = &s.doc().unwrap().node(group).unwrap().child_layout; + assert_eq!(cl.sizing_h, if h == "fill" { Sizing::Fill } else { Sizing::Hug }); + assert_eq!(cl.sizing_v, if v == "fill" { Sizing::Fill } else { Sizing::Hug }); + assert_eq!([page_box(&s, group), page_box(&s, sibling)], before); + s.undo().unwrap(); + assert!(matches!(kind(&s, group), NodeKind::Group)); + assert_eq!([page_box(&s, group), page_box(&s, sibling)], before); + } +} + #[test] fn a_frame_becomes_a_group_around_its_layers() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index 2d8f6287..692673e4 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -148,6 +148,33 @@ fn id(v: serde_json::Value) -> NodeId { NodeId(v["id"].as_u64().unwrap()) } +#[test] +fn layout_add_button_requires_every_selected_container_to_have_no_layout() { + let mut s = Session::new(); + let rect = id(s.execute("shape.rectangle", &json!({"width": 40, "height": 20})).unwrap()); + let group = id(s.execute("object.group", &json!({"ids": [rect]})).unwrap()); + let frame = id(s.execute("frame.create", &json!({"x": 200, "width": 100, "height": 60})).unwrap()); + let mut u = Ui::new(s); + u.ctx.enable_accesskit(); + for (selection, layout, expected) in [ + (vec![group], false, true), + (vec![frame, group], false, true), + (vec![frame, group], true, false), + (vec![frame], true, false), + (vec![], true, false), + ] { + if layout { + u.app.session.execute("node.set", &json!({"id": frame, "props": {"layout": "horizontal"}})).unwrap(); + } + u.app.session.execute("select.set", &json!({"ids": selection})).unwrap(); + u.frame(vec![]); + let output = u.frame_output(vec![]); + let nodes = output.platform_output.accesskit_update.unwrap().nodes; + let has_add = nodes.iter().any(|(_, n)| n.role() == Role::Button && n.label() == Some("Add auto layout")); + assert_eq!(has_add, expected, "selection: {selection:?}, frame has layout: {layout}"); + } +} + /// Rectangles A (10, 20, 40×30) and B (100, 50, 20×20); A is selected. fn setup() -> (Ui, NodeId, NodeId) { let mut s = Session::new(); diff --git a/crates/ui-egui/src/panels/layout_panel.rs b/crates/ui-egui/src/panels/layout_panel.rs index c2938539..b21e074c 100644 --- a/crates/ui-egui/src/panels/layout_panel.rs +++ b/crates/ui-egui/src/panels/layout_panel.rs @@ -23,10 +23,13 @@ fn parent_of(doc: &Document, id: NodeId) -> Option<&Node> { doc.node(id).and_then(|n| n.parent).and_then(|p| doc.node(p)) } +/// Show selection sizing and layout controls; offer Add only when every selected container has +/// no auto layout and the selection is editable outside an instance. pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let ids = sel.to_vec(); let frames = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like())); let layout_containers = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Group))); + let no_al = !sel.is_empty() && sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.layout.is_none())); let al = common(doc, sel, |n| n.layout.clone()); let has_al = matches!(al, Some(Some(_))); let title = if has_al { tr("Auto layout") } else { tr("Layout") }; @@ -36,7 +39,7 @@ pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node let nested = sel.iter().any(|i| figcraft_engine::components::inside_instance(doc, *i)); let btns: Vec<(Icon, &str)> = if in_instance { vec![] - } else if layout_containers && !has_al { + } else if layout_containers && no_al { vec![(Icon::Plus, crate::shortcuts::tip(tr("Add auto layout"), "layout.add"))] } else if has_al { vec![(Icon::Minus, crate::shortcuts::tip(tr("Remove auto layout"), "layout.remove"))] diff --git a/docs/auto-layout-parity.md b/docs/auto-layout-parity.md index 5b5d85a1..5dfc3c8e 100644 --- a/docs/auto-layout-parity.md +++ b/docs/auto-layout-parity.md @@ -1,6 +1,6 @@ # Auto layout and constraints parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-29 · **Change:** minor (Layout sidebar: Add auto layout (+) is available for groups and converts a single group to a frame in place; adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys only to frames, with warnings (#603); core editor leftovers: auto gap handles, wrapped-line alignment; behaviour suite written from the public documentation, every rule's status; fixes and the missing settings it found) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-29 · **Change:** minor (Layout sidebar: Add auto layout (+) is available for groups and converts a single group to a frame in place, with mask order, flow-only inference and Fill sizing preserved; adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys only to frames, with warnings (#603); core editor leftovers: auto gap handles, wrapped-line alignment; behaviour suite written from the public documentation, every rule's status; fixes and the missing settings it found) · **Target:** Figma Design (desktop app 126.10.7) Auto layout is the most used layout feature in Figma and the one where small differences (a hugging frame one pixel off, text wrapping at another width) break real files. **Breadth ~98%, @@ -14,7 +14,11 @@ Figma files. Remaining: 3–6 h. With one group selected, Add auto layout converts that group into a frame in place, retaining its id, name, parent and direct children. The Layout sidebar’s +, context menu, Shift+A and API calls all dispatch `layout.add`, so they share the conversion and a single undo step restores the group. -The layout settings are inferred from the children, as for an existing frame. +The layout settings are inferred from visible, non-absolute children, matching the layout engine’s +flow. Hidden and absolute children therefore do not distort direction, gap or padding. A converted +group keeps Fill sizing on either axis in its auto layout parent. Sorting by position preserves +mask boundaries and the paint order within masked runs. The sidebar offers Add only when every +selected container has no auto layout, including mixed selections. This follows the Figma behavior reported by the contributor and the sidebar workflow users know from similar design tools: start with a group and make its children participate in auto layout. @@ -91,6 +95,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M2 review fixes: conversion preserves mask runs and Fill sizing per axis; layout inference uses visible, non-absolute flow children; the sidebar hides Add for mixed selections containing existing auto layout. Regression coverage includes rendered mask pixels, visible-child positions, parent layout stability and the sidebar accessibility tree. Numbers unchanged | | 2026-10-29 | minor | M2: the Layout sidebar exposes Add auto layout (+) for groups. `layout.add` converts one selected group into a frame in place rather than wrapping it: same id, name and direct children; the layout arranges those children, and one undo restores the group. Sidebar, menu, shortcut and API calls all use the corrected command. This follows the contributor’s observed Figma behavior and the workflow familiar to users of similar design tools. Tests cover direct-child reflow, stable hierarchy, undo/redo and wrapping a multi-group selection. Numbers unchanged | | 2026-10-17 | minor | Community batch #753: adding auto layout to an empty frame keeps its size, Fixed, while a frame with content hugs it (#369); `node.set` applies auto layout keys to the frames among its targets and warns about the others, and a font style the family lacks warns with the nearest face (#603). Checked over MCP. Numbers unchanged | | 2026-10-13 | minor | Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged. | From 96eed15993b1bbaf5bf31ead089effbdda1f430c Mon Sep 17 00:00:00 2001 From: BFlat Date: Thu, 29 Oct 2026 21:33:46 +0700 Subject: [PATCH 37/63] M8: mask groups draw over their own area, nested layers share one memory budget (#1061) (#1535) A mask group's pixmaps cover only where its mask or content can paint within the view, rounded out to whole pixels, drawing the same pixels (a 40x40 mask on a 3000x2000 view: 96 MB -> 61.5 kB). Isolated layers share a 2 GiB per-thread budget; a layer past it is left out with a warning. --- ROADMAP.md | 3 +- crates/render/src/draw.rs | 112 ++++++++++++++------ crates/render/src/layer_budget.rs | 59 +++++++++++ crates/render/src/lib.rs | 3 + crates/render/src/mask_bounds_tests.rs | 141 +++++++++++++++++++++++++ docs/roadmap.md | 3 +- 6 files changed, 287 insertions(+), 34 deletions(-) create mode 100644 crates/render/src/layer_budget.rs create mode 100644 crates/render/src/mask_bounds_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 9622e3d7..5e6eea88 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-29 · **Change:** minor (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-10-29 · **Change:** minor (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 @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M8, #1061: a mask group's pixmaps (the mask, its own layer, the content and the pixels it goes onto) cover only where the mask or its content can paint (effects included) within the view, rounded out to whole pixels, instead of the whole view: a 40 × 40 mask on a 3000 × 2000 view held 96 MB, now 61.5 kB, drawing the same pixels. Isolated layers (effects, opacity, masks, clipping frames) also share one budget of 2 GiB held at once per render thread (`figcraft_render::layer_budget`); a layer past it is left out with a warning, so 64 nested full-view layers no longer ask for tens of GB. Tests: `mask_bounds_tests`. Numbers unchanged | | 2026-10-29 | minor | M2: components can't contain their own instances (#1084). Any edit that would make a component hold an instance of itself, directly or through other components (an instance-swap property's current value included), is refused with the loop named: creating, placing, swapping, switching variants, moving, pasting and instance-swap values. Each variant counts as a component of its own, so a variant holding a sibling variant's instance is fine unless that one leads back to it (`components::check_component_cycles`, beside the instance structure check); a dropped instance lands on the page instead. A file holding such a loop (opened or imported) has one instance per loop detached, with a note; a loop merged in a live session is reported, and a rebuild never expands an instance inside a copy of itself, so its copies stop growing (12 rebuilds of a two-component loop: 25 → 113 layers before, 25 throughout now). Tests: `component_cycle_tests`. Numbers unchanged | | 2026-10-29 | minor | Which copy is which (M8, #995): `edit.duplicate` with `map: true` also returns `map`, each original layer's id, descendants included (nested frames, groups, instances and their sublayers), paired with its copy's id, so a batch can edit inside a copy through a step reference (`"field": "map/6"`) instead of inspecting it. Without `map` the result is unchanged. Regression tests in `crates/engine/src/duplicate_map_tests.rs`. Numbers unchanged | | 2026-10-29 | minor | Bounded replay log (#1069, from a security audit): the batches a session keeps for apps that reconnect were bounded only by count (10,000 batches, 1,000,000 changes) on a desktop host and a private `figcraft-server`, so a long busy session could hold any number of bytes. `Limits::max_log_bytes` now defaults to 64 MiB, and `figcraft-server` takes it from a new `max_log_mb` setting (default 64; open to the public, also at most a quarter of `max_file_mb`, as before). An app further behind than the trimmed log catches up with the whole file, as it already did past the count limits. Nine 8 MiB renames with default limits kept all 75.5 MB before; now 58.7 MB, and a client that missed them gets a snapshot. The protocol is unchanged. | diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 34bb5645..4ef427e4 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -594,6 +594,8 @@ fn draw_layer(ctx: &Ctx, pix: &mut Pixmap, n: &Node, parent_xf: Affine, depth: u let (lx, ly) = (x0 as i32, y0 as i32); let lw = w.min(16_384.0) as u32; let lh = h.min(16_384.0) as u32; + // Nested layers hold their pixmaps while the ones inside them draw: within one budget. + let Some(_held) = crate::layer_budget::Held::take(lw, lh, 1) else { return }; let Some(mut layer) = Pixmap::new(lw, lh) else { return }; let lxf = Affine::translate((-f64::from(lx), -f64::from(ly))) * xf; let shape = node_outline(ctx.doc, n); @@ -894,52 +896,98 @@ fn draw_children(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize } } -fn draw_masked(ctx: &Ctx, pix: &mut Pixmap, mask_id: NodeId, content: &[NodeId], xf: Affine, depth: usize) { - let (w, h) = (pix.width(), pix.height()); - let Some(mut mask_px) = Pixmap::new(w, h) else { return }; - let mask_type = ctx.doc.node(mask_id).map(|m| m.mask_type).unwrap_or_default(); - // Draw the mask node as if it weren't a mask (a vector mask: its outline, fully opaque). - if let Some(m) = ctx.doc.node(mask_id) { - let mut m2 = m.clone(); - m2.is_mask = false; - let mxf = xf * m2.transform; - if mask_type == figcraft_doc::MaskType::Vector { - if let Some((outline, rule)) = subtree_outline(ctx.doc, &m2, depth + 1) - && let Some(path) = ts_path(&outline) - { - let mut paint = tiny_skia::Paint::default(); - paint.set_color_rgba8(255, 255, 255, 255); - paint.anti_alias = true; - mask_px.fill_path(&path, &paint, rule, ts_transform(mxf), None); - } - } else if needs_layer(&m2) { - let paint = PixmapPaint { opacity: m2.opacity, ..Default::default() }; - let mut tmp = Pixmap::new(w, h); - if let Some(tmp) = tmp.as_mut() { - draw_content(ctx, tmp, &m2, mxf, depth + 1, None); - mask_px.draw_pixmap(0, 0, tmp.as_ref(), &paint, Transform::identity(), None); - } - } else { - draw_content(ctx, &mut mask_px, &m2, mxf, depth + 1, None); - } +#[cfg(test)] +thread_local! { + /// Tests: mask groups draw over the whole pixmap, as they did before [`masked_area`]. + pub(crate) static WHOLE_MASK_AREA: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// The pixels of `pix` a mask group (mask `mask_id`, then `content`, drawn at `xf`) can paint, +/// rounded out to whole pixels: where the mask or the content reach, effects included (#1061). +fn masked_area(ctx: &Ctx, pix: &Pixmap, mask_id: NodeId, content: &[NodeId], xf: Affine) -> Option { + let size = (pix.width(), pix.height()); + let whole = Rect::new(0.0, 0.0, f64::from(size.0), f64::from(size.1)); + #[cfg(test)] + if WHOLE_MASK_AREA.with(std::cell::Cell::get) { + return int_rect(whole, size); } + let reach = |id: &NodeId| { + let n = ctx.bounds.node(ctx.doc, *id)?; + let (local, levels) = bounds_within(ctx.doc, n, 0, ctx.lod.then_some(ctx.scale), Some(ctx.bounds)); + // Bounds the nesting cap cut short could miss a layer: anywhere then. + if levels.is_none() { + return Some(whole); + } + // A pixel more for antialiasing. + let m = (effect_margin(n) * ctx.scale).min(MAX_EFFECT_MARGIN_PX) + 1.0; + Some(device_rect(xf * n.transform, local).inflate(m, m)) + }; + let area = std::iter::once(&mask_id).chain(content).filter_map(reach).reduce(|a, b| a.union(b))?; + int_rect(area, size) +} + +/// Mask `m` drawn as if it weren't a mask into a `w` × `h` pixmap at `xf` (a vector mask: its +/// outline, fully opaque). +fn mask_pixels(ctx: &Ctx, m: &Node, (w, h): (u32, u32), xf: Affine, depth: usize) -> Option { + let mut mask_px = Pixmap::new(w, h)?; + let mut m2 = m.clone(); + m2.is_mask = false; + let mxf = xf * m2.transform; + if m.mask_type == figcraft_doc::MaskType::Vector { + if let Some((outline, rule)) = subtree_outline(ctx.doc, &m2, depth + 1) + && let Some(path) = ts_path(&outline) + { + let mut paint = tiny_skia::Paint::default(); + paint.set_color_rgba8(255, 255, 255, 255); + paint.anti_alias = true; + mask_px.fill_path(&path, &paint, rule, ts_transform(mxf), None); + } + } else if needs_layer(&m2) { + let paint = PixmapPaint { opacity: m2.opacity, ..Default::default() }; + if let Some(mut tmp) = Pixmap::new(w, h) { + draw_content(ctx, &mut tmp, &m2, mxf, depth + 1, None); + mask_px.draw_pixmap(0, 0, tmp.as_ref(), &paint, Transform::identity(), None); + } + } else { + draw_content(ctx, &mut mask_px, &m2, mxf, depth + 1, None); + } + Some(mask_px) +} + +fn draw_masked(ctx: &Ctx, pix: &mut Pixmap, mask_id: NodeId, content: &[NodeId], xf: Affine, depth: usize) { + // Only over the pixels the group can paint, not the whole pixmap. + let Some(area) = masked_area(ctx, pix, mask_id, content, xf) else { return }; + let (ax, ay, w, h) = (area.x(), area.y(), area.width(), area.height()); + // The mask (and its own layer), the content, and the pixels it goes onto. + let Some(_held) = crate::layer_budget::Held::take(w, h, 4) else { return }; + let axf = Affine::translate((-f64::from(ax), -f64::from(ay))) * xf; + let m = ctx.doc.node(mask_id); + let mask_px = match m { + Some(m) => mask_pixels(ctx, m, (w, h), axf, depth), + None => Pixmap::new(w, h), + }; + let Some(mask_px) = mask_px else { return }; let Some(mut layer) = Pixmap::new(w, h) else { return }; - let kind = if mask_type == figcraft_doc::MaskType::Luminance { MaskType::Luminance } else { MaskType::Alpha }; + let kind = if m.is_some_and(|m| m.mask_type == figcraft_doc::MaskType::Luminance) { MaskType::Luminance } else { MaskType::Alpha }; let mask = Mask::from_pixmap(mask_px.as_ref(), kind); { - let under = Under { clip: Some(&mask), ..Under::new(pix, (0, 0), ctx.under) }; + let under = Under { clip: Some(&mask), ..Under::new(pix, (ax, ay), ctx.under) }; let sub = Ctx { under: Some(&under), ..*ctx }; for c in content { - draw_node(&sub, &mut layer, *c, xf, depth + 1); + draw_node(&sub, &mut layer, *c, axf, depth + 1); } } - pix.draw_pixmap(0, 0, layer.as_ref(), &PixmapPaint::default(), Transform::identity(), Some(&mask)); + // A mask applies to a pixmap of its own size: through it onto a copy of the area, put back. + let Some(mut onto) = pix.clone_rect(area) else { return }; + onto.draw_pixmap(0, 0, layer.as_ref(), &PixmapPaint::default(), Transform::identity(), Some(&mask)); + backdrop::replace(pix, (ax, ay), &onto, None); } fn draw_children_clipped(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize, clip: Option<&BezPath>) { let (pw, ph) = (pix.width(), pix.height()); let dev = device_rect(xf, n.local_rect()); let Some(ir) = int_rect(dev, (pw, ph)) else { return }; + let Some(_held) = crate::layer_budget::Held::take(ir.width(), ir.height(), 1) else { return }; let (lx, ly) = (ir.x(), ir.y()); // The layer starts as what lies under it and goes back in its place through the clip: // clipping cuts the children off without isolating them, so their blend modes reach the diff --git a/crates/render/src/layer_budget.rs b/crates/render/src/layer_budget.rs new file mode 100644 index 00000000..975ee6c6 --- /dev/null +++ b/crates/render/src/layer_budget.rs @@ -0,0 +1,59 @@ +//! One budget for the isolated layers a render holds at once (#1061): each nested layer with +//! effects, opacity or a mask keeps its pixmaps while the layers inside it draw, so a deep nest of +//! large ones could ask for more memory than there is. A layer past the budget is left out (with +//! a warning, once) instead. + +use std::cell::Cell; +use std::sync::atomic::{AtomicBool, Ordering}; + +/// Bytes the isolated layers being drawn on one thread may hold at once: two of the largest layer +/// there is (16 384 px square). +pub const BUDGET: u64 = 2 << 30; + +thread_local! { + /// Bytes held now on this thread, the most held in this thread's life, and the budget + /// (tests lower it). + static HELD: Cell = const { Cell::new(0) }; + static PEAK: Cell = const { Cell::new(0) }; + static LIMIT: Cell = const { Cell::new(BUDGET) }; +} + +static WARNED: AtomicBool = AtomicBool::new(false); + +/// A share of the budget, given back when dropped (on unwinding too). +pub(crate) struct Held(u64); + +impl Held { + /// Room for `count` RGBA pixmaps of `w` × `h` pixels, if the budget has it. + pub(crate) fn take(w: u32, h: u32, count: u64) -> Option { + let bytes = u64::from(w) * u64::from(h) * 4 * count; + let held = HELD.with(Cell::get); + let total = held.saturating_add(bytes); + if total > LIMIT.with(Cell::get) { + if !WARNED.swap(true, Ordering::Relaxed) { + log::warn!("render: nested layers need more than {} MB at once; the innermost are left out", LIMIT.with(Cell::get) >> 20); + } + return None; + } + HELD.with(|c| c.set(total)); + PEAK.with(|c| c.set(c.get().max(total))); + Some(Held(bytes)) + } +} + +impl Drop for Held { + fn drop(&mut self) { + HELD.with(|c| c.set(c.get().saturating_sub(self.0))); + } +} + +/// Tests: run `f` with a budget of `limit` bytes on this thread; returns what it gave and the most +/// it held at once. +#[cfg(test)] +pub(crate) fn within(limit: u64, f: impl FnOnce() -> T) -> (T, u64) { + let was = LIMIT.with(|c| c.replace(limit)); + PEAK.with(|c| c.set(HELD.with(Cell::get))); + let out = f(); + LIMIT.with(|c| c.set(was)); + (out, PEAK.with(Cell::get)) +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index a357033d..9f2064fc 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -16,6 +16,7 @@ pub mod glass; pub mod grain; pub mod image_profile; pub mod images; +mod layer_budget; pub mod lod; pub mod paint; pub mod pattern; @@ -505,6 +506,8 @@ mod lod_tests; #[cfg(test)] mod magnified_image_tests; #[cfg(test)] +mod mask_bounds_tests; +#[cfg(test)] mod paint_behaviour_tests; #[cfg(test)] mod progressive_tests; diff --git a/crates/render/src/mask_bounds_tests.rs b/crates/render/src/mask_bounds_tests.rs new file mode 100644 index 00000000..841542e1 --- /dev/null +++ b/crates/render/src/mask_bounds_tests.rs @@ -0,0 +1,141 @@ +//! Mask groups draw only over the pixels they can paint, and nested isolated layers share one +//! memory budget (#1061). Each scene renders the same with a mask's pixmaps cut to its area as with +//! the whole view (how masks drew before). + +use figcraft_color::{Color, Paint}; +use figcraft_doc::{Document, Effect, EffectKind, MaskType, NodeId, NodeKind, Shadow}; +use figcraft_geom::Affine; +use tiny_skia::Pixmap; + +use crate::draw::WHOLE_MASK_AREA; +use crate::layer_budget::{BUDGET, within}; +use crate::{Options, View, render_view_single}; + +const ELLIPSE: NodeKind = NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }; + +fn shape(d: &mut Document, parent: NodeId, kind: NodeKind, (x, y, w, h): (f64, f64, f64, f64), hex: &str) -> NodeId { + let id = d.create(parent, kind, "Shape", x, y, w, h).unwrap(); + d.get_mut(id).unwrap().fills = vec![Paint::solid(Color::from_hex(hex).unwrap())]; + id +} + +/// A group under `parent` whose first layer, `mask`, masks the rest. +fn mask_group(d: &mut Document, parent: NodeId, kind: NodeKind, rect: (f64, f64, f64, f64), mask_type: MaskType) -> (NodeId, NodeId) { + let g = d.create(parent, NodeKind::Group, "Mask group", 0.0, 0.0, 1.0, 1.0).unwrap(); + let m = shape(d, g, kind, rect, "808080"); + let n = d.get_mut(m).unwrap(); + n.is_mask = true; + n.mask_type = mask_type; + (g, m) +} + +fn turn(d: &mut Document, id: NodeId, degrees: f64) { + let n = d.get_mut(id).unwrap(); + let (cx, cy) = (n.width / 2.0, n.height / 2.0); + n.transform = n.transform * Affine::translate((cx, cy)) * Affine::rotate(degrees.to_radians()) * Affine::translate((-cx, -cy)); +} + +/// Nested masks (alpha, luminance, vector, a mask with opacity), rotated masks and content, +/// content with opacity, a drop shadow and a background blur, and a mask group running off the +/// right and bottom of an 800 × 600 view. +fn scene() -> (Document, NodeId) { + let mut d = Document::new("masks"); + let p = d.first_page().unwrap(); + shape(&mut d, p, NodeKind::Rectangle, (0.0, 0.0, 800.0, 600.0), "EEEEEE"); + let (g1, _) = mask_group(&mut d, p, ELLIPSE, (100.0, 80.0, 320.0, 220.0), MaskType::Alpha); + let c1 = shape(&mut d, g1, NodeKind::Rectangle, (60.0, 50.0, 400.0, 300.0), "E03030"); + let n = d.get_mut(c1).unwrap(); + n.opacity = 0.6; + n.effects = vec![Effect { + kind: EffectKind::DropShadow(Shadow { color: Color::BLACK, offset: (8.0, 12.0), radius: 10.0, ..Shadow::default() }), + visible: true, + }]; + let c2 = shape(&mut d, g1, NodeKind::Rectangle, (150.0, 120.0, 160.0, 90.0), "2040E0"); + turn(&mut d, c2, 30.0); + let pane = shape(&mut d, g1, NodeKind::Rectangle, (120.0, 100.0, 140.0, 80.0), "FFFFFF33"); + d.get_mut(pane).unwrap().effects = vec![Effect { kind: EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(6.0)), visible: true }]; + // Nested: a turned luminance mask inside the first group. + let (g2, m2) = mask_group(&mut d, g1, NodeKind::Rectangle, (200.0, 140.0, 150.0, 110.0), MaskType::Luminance); + turn(&mut d, m2, 20.0); + let c3 = shape(&mut d, g2, ELLIPSE, (180.0, 120.0, 200.0, 160.0), "20C060"); + d.get_mut(c3).unwrap().opacity = 0.8; + // A vector mask with opacity running off the view. + let (g3, m3) = mask_group(&mut d, p, NodeKind::Star { count: 5, inner_ratio: 0.5 }, (640.0, 420.0, 300.0, 300.0), MaskType::Vector); + d.get_mut(m3).unwrap().opacity = 0.5; + let c4 = shape(&mut d, g3, NodeKind::Rectangle, (560.0, 380.0, 500.0, 400.0), "F0A020"); + turn(&mut d, c4, -15.0); + // An alpha mask with opacity: its own layer. + let (g4, m4) = mask_group(&mut d, p, NodeKind::Rectangle, (20.0, 420.0, 200.0, 150.0), MaskType::Alpha); + d.get_mut(m4).unwrap().opacity = 0.4; + shape(&mut d, g4, ELLIPSE, (0.0, 400.0, 260.0, 190.0), "8020C0"); + (d, p) +} + +fn render(d: &Document, page: NodeId, view: &View, whole: bool) -> Pixmap { + WHOLE_MASK_AREA.with(|c| c.set(whole)); + let pix = render_view_single(d, page, view, &Options { background: Some(Color::WHITE), ..Default::default() }); + WHOLE_MASK_AREA.with(|c| c.set(false)); + pix.unwrap() +} + +#[test] +fn masks_cut_to_their_area_draw_the_same() { + let (d, page) = scene(); + let views = [ + View { origin: (0.0, 0.0), scale: 1.0, width: 800, height: 600 }, + // Fractional origin and zoom: the area's edges fall between pixels. + View { origin: (-37.3, 12.6), scale: 1.7, width: 900, height: 700 }, + View { origin: (300.0, 200.0), scale: 0.45, width: 320, height: 240 }, + // Only part of each group in view. + View { origin: (250.0, 250.0), scale: 2.0, width: 500, height: 400 }, + ]; + for v in &views { + let (before, after) = (render(&d, page, v, true), render(&d, page, v, false)); + let differ = before.data().chunks(4).zip(after.data().chunks(4)).filter(|(a, b)| a != b).count(); + assert_eq!(differ, 0, "{v:?}: {differ} pixels differ"); + let colours: std::collections::BTreeSet<&[u8]> = after.data().chunks(4).collect(); + assert!(colours.len() > 3, "{v:?}: the masks show their content"); + } +} + +#[test] +fn a_small_mask_holds_a_small_area() { + let mut d = Document::new("small mask"); + let p = d.first_page().unwrap(); + let (g, _) = mask_group(&mut d, p, ELLIPSE, (100.0, 100.0, 40.0, 40.0), MaskType::Alpha); + shape(&mut d, g, NodeKind::Rectangle, (90.0, 90.0, 60.0, 60.0), "E03030"); + let v = View { origin: (0.0, 0.0), scale: 1.0, width: 3000, height: 2000 }; + let (before, whole) = within(BUDGET, || render(&d, p, &v, true)); + let (after, cut) = within(BUDGET, || render(&d, p, &v, false)); + assert_eq!(before.data(), after.data()); + // Four 3000 × 2000 pixmaps before (96 MB), four 62 × 62 now (61.5 kB). + assert_eq!(whole, 4 * 4 * 3000 * 2000); + assert_eq!(cut, 4 * 4 * 62 * 62, "the mask's 40 × 40 plus its content, rounded out and a pixel round"); +} + +#[test] +fn a_deep_nest_of_isolated_layers_stays_within_the_budget() { + let mut d = Document::new("deep"); + let p = d.first_page().unwrap(); + let mut parent = p; + // 64 levels, each a full-view group at half opacity or a mask group, every one isolating. + for i in 0..64 { + parent = if i % 2 == 0 { + let g = d.create(parent, NodeKind::Group, "Group", 0.0, 0.0, 1.0, 1.0).unwrap(); + d.get_mut(g).unwrap().opacity = 0.5; + g + } else { + mask_group(&mut d, parent, NodeKind::Rectangle, (0.0, 0.0, 1000.0, 1000.0), MaskType::Alpha).0 + }; + shape(&mut d, parent, NodeKind::Rectangle, (0.0, 0.0, 1000.0, 1000.0), "2040E0"); + } + let v = View { origin: (0.0, 0.0), scale: 1.0, width: 500, height: 500 }; + // Unbounded, the nest holds all its levels at once. + let (_, all) = within(u64::MAX, || render(&d, p, &v, false)); + assert_eq!(all, 32 * 4 * 500 * 500 * 5, "32 groups of one layer, 32 mask groups of four"); + let limit = 16 << 20; + let (pix, peak) = within(limit, || render(&d, p, &v, false)); + assert!(peak <= limit, "held {peak} bytes at once"); + // The outer levels still draw (the innermost, past the budget, are left out). + assert_ne!(&pix.data()[..4], &[255, 255, 255, 255]); +} diff --git a/docs/roadmap.md b/docs/roadmap.md index df1ab2d4..2d4d0633 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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-10-28 · **Change:** minor (M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M8, #1061: a mask group's pixmaps cover only where its mask or content can paint within the view (a 40 × 40 mask on a 3000 × 2000 view: 96 MB → 61.5 kB, same pixels), and isolated layers share a 2 GiB budget held at once per render thread; a layer past it is left out with a warning. Tests: `mask_bounds_tests` | | 2026-10-29 | minor | M2, #1084: a component can't contain its own instance, directly or through other components (each variant counts as its own component, so a variant may hold a sibling variant's instance unless that one leads back to it). Edits that would close such a loop are refused with it named, a file holding one opens with an instance detached and a note, and a rebuild never expands an instance inside a copy of itself (a loop merged in a live session stops growing). Tests: `component_cycle_tests` | | 2026-10-29 | minor | Which copy is which (M8, #995): `edit.duplicate` with `map: true` also returns `map`, each original layer's id, descendants included (nested frames, groups, instances and their sublayers), paired with its copy's id, so a batch can edit inside a copy through a step reference (`"field": "map/6"`) instead of inspecting it. Without `map` the result is unchanged. Regression tests in `crates/engine/src/duplicate_map_tests.rs`. Numbers unchanged | | 2026-10-29 | minor | Bounded replay log (#1069, from a security audit): the batches a session keeps for apps that reconnect were bounded only by count (10,000 batches, 1,000,000 changes) on a desktop host and a private `figcraft-server`, so a long busy session could hold any number of bytes. `Limits::max_log_bytes` now defaults to 64 MiB, and `figcraft-server` takes it from a new `max_log_mb` setting (default 64; open to the public, also at most a quarter of `max_file_mb`, as before). An app further behind than the trimmed log catches up with the whole file, as it already did past the count limits. Nine 8 MiB renames with default limits kept all 75.5 MB before; now 58.7 MB, and a client that missed them gets a snapshot. The protocol is unchanged. | From cd0dc748b8a5da901e7152b1af4922de72ad3d05 Mon Sep 17 00:00:00 2001 From: Carbocode Date: Thu, 29 Oct 2026 15:41:55 +0100 Subject: [PATCH 38/63] M1: keep the shared roadmap out of the colour picker fix --- ROADMAP.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c69723a0..9622e3d7 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-29 · **Change:** minor (colour picker outside-click dismissal and sidebar anchoring; review fixes for text undo, fitted sidebars, moving controls and gradient variables; 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-10-29 · **Change:** minor (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 @@ -355,8 +355,6 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| -| 2026-10-29 | minor | Colour picker review follow-up: opening the first picker preserves a text undo interaction; placement follows the rendered sidebar; opening controls track their widget ids while moving; linked selection-colour names toggle closed; gradient variable-picker presses keep the parent open. Regression tests cover each case; estimates unchanged. | -| 2026-10-29 | minor | Colour pickers dismiss on presses outside their card, including the sidebar, and open 8 pt beside the sidebar aligned to their control (lifted to fit the screen); switching targets resets a dragged position and commits pending edits. Eyedropper sampling stays open. UI regression tests cover added fills/strokes and page colours; headline estimates unchanged. | | 2026-10-29 | minor | M2: components can't contain their own instances (#1084). Any edit that would make a component hold an instance of itself, directly or through other components (an instance-swap property's current value included), is refused with the loop named: creating, placing, swapping, switching variants, moving, pasting and instance-swap values. Each variant counts as a component of its own, so a variant holding a sibling variant's instance is fine unless that one leads back to it (`components::check_component_cycles`, beside the instance structure check); a dropped instance lands on the page instead. A file holding such a loop (opened or imported) has one instance per loop detached, with a note; a loop merged in a live session is reported, and a rebuild never expands an instance inside a copy of itself, so its copies stop growing (12 rebuilds of a two-component loop: 25 → 113 layers before, 25 throughout now). Tests: `component_cycle_tests`. Numbers unchanged | | 2026-10-29 | minor | Which copy is which (M8, #995): `edit.duplicate` with `map: true` also returns `map`, each original layer's id, descendants included (nested frames, groups, instances and their sublayers), paired with its copy's id, so a batch can edit inside a copy through a step reference (`"field": "map/6"`) instead of inspecting it. Without `map` the result is unchanged. Regression tests in `crates/engine/src/duplicate_map_tests.rs`. Numbers unchanged | | 2026-10-29 | minor | Bounded replay log (#1069, from a security audit): the batches a session keeps for apps that reconnect were bounded only by count (10,000 batches, 1,000,000 changes) on a desktop host and a private `figcraft-server`, so a long busy session could hold any number of bytes. `Limits::max_log_bytes` now defaults to 64 MiB, and `figcraft-server` takes it from a new `max_log_mb` setting (default 64; open to the public, also at most a quarter of `max_file_mb`, as before). An app further behind than the trimmed log catches up with the whole file, as it already did past the count limits. Nine 8 MiB renames with default limits kept all 75.5 MB before; now 58.7 MB, and a client that missed them gets a snapshot. The protocol is unchanged. | From 4f67fd2b7cc6b4a39a032f3b39adb96e37c29790 Mon Sep 17 00:00:00 2001 From: Carbocode Date: Thu, 29 Oct 2026 15:41:58 +0100 Subject: [PATCH 39/63] M2: keep the shared roadmap out of the auto layout fix --- ROADMAP.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 26ab4e04..cf7c7cb6 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-29 · **Change:** minor (Layout sidebar: groups expose Add auto layout and convert to frames in place, preserving masks, flow spacing and Fill sizing; `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-10-29 · **Change:** minor (`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 @@ -355,8 +355,6 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| -| 2026-10-29 | minor | M2 review fixes: adding auto layout preserves mask run order and a converted group’s Fill sizing; direction, gap and padding are inferred only from visible, non-absolute children. The sidebar hides Add when any selected container already has auto layout. Render, layout and sidebar regressions cover the review findings. Numbers unchanged | -| 2026-10-29 | minor | M2: the Design sidebar’s Layout header shows Add auto layout (+) for groups as well as frames. `layout.add` converts a single selected group into a frame in place, retaining its id, name and direct children, so auto layout arranges those children instead of treating the whole group as one fixed item inside a new wrapper. This matches the Figma workflow reported by the contributor and the sidebar workflow familiar to users of similar design tools. Sidebar, context menu, shortcut and programmatic callers share the fix; conversion and layout undo together. Regression tests in `convert_container_tests`. Numbers unchanged | | 2026-10-29 | minor | Which copy is which (M8, #995): `edit.duplicate` with `map: true` also returns `map`, each original layer's id, descendants included (nested frames, groups, instances and their sublayers), paired with its copy's id, so a batch can edit inside a copy through a step reference (`"field": "map/6"`) instead of inspecting it. Without `map` the result is unchanged. Regression tests in `crates/engine/src/duplicate_map_tests.rs`. Numbers unchanged | | 2026-10-29 | minor | Bounded replay log (#1069, from a security audit): the batches a session keeps for apps that reconnect were bounded only by count (10,000 batches, 1,000,000 changes) on a desktop host and a private `figcraft-server`, so a long busy session could hold any number of bytes. `Limits::max_log_bytes` now defaults to 64 MiB, and `figcraft-server` takes it from a new `max_log_mb` setting (default 64; open to the public, also at most a quarter of `max_file_mb`, as before). An app further behind than the trimmed log catches up with the whole file, as it already did past the count limits. Nine 8 MiB renames with default limits kept all 75.5 MB before; now 58.7 MB, and a client that missed them gets a snapshot. The protocol is unchanged. | | 2026-10-29 | minor | M8, #998: MCP tools that act on a document (`run_command`, `batch`, `create_node`, `set_properties`, `inspect_document`, `get_node`, `select`, `export`, `screenshot`, `save_file`, `undo`, `redo`) take an optional `document`, an open file's `id` (`doc.list` and the `document.inspect` outline now carry it) or path, and so do the control methods `engine.execute`, `document.inspect`, `node.get` and `ui.render`: the call acts on that file and the tab in front stays in front, so an attached agent keeps editing its own file when the user switches tabs. Calls without it act on the file in front as before; other control methods refuse a `document` behind the tab in front. Numbers unchanged | From 2d507d959dbf363e4b753736b3a99a6c73f3f277 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 14:44:18 +0000 Subject: [PATCH 40/63] M12: board grid settings: dots, lines, graph paper or none The board canvas's background grid is now a preference (`board.grid`): dots (the default), lines, graph paper (a strong line every 2-12 cells over fainter ones, like Japanese section paper) or none, with spacing (4-512), mark size and contrast. Preferences gets a Board grid section; right-clicking an empty board offers Board grid settings. Zoomed out the grid still thins out (by halves, or down to graph paper's strong lines), and dots no longer shrink to a single pixel, so the grid stays visible at low zoom. The renderer draws lines as well as dots, with a configurable strong-mark rhythm; a crossing of two lines is blended once, in the stronger colour. A hand-edited preferences file falls back to the default grid instead of losing the other preferences. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FEkCVd8n1eC6xTHebmHMDR --- crates/engine/src/board_grid_tests.rs | 45 ++++++++ crates/engine/src/boards.rs | 1 + crates/engine/src/boards/board_grid.rs | 138 +++++++++++++++++++++++++ crates/engine/src/cmd.rs | 1 + crates/engine/src/lib.rs | 3 + crates/engine/src/prefs.rs | 4 + crates/render/src/dots.rs | 118 ++++++++++++++++++--- crates/render/src/dots_tests.rs | 50 ++++++++- crates/ui-egui/locales/en.toml | 5 + crates/ui-egui/src/board_ui_tests.rs | 39 ++++++- crates/ui-egui/src/canvas/board_bar.rs | 118 +++++++++++++++++---- crates/ui-egui/src/dialogs.rs | 2 + crates/ui-egui/src/menus.rs | 4 + docs/boards-parity.md | 5 +- 14 files changed, 492 insertions(+), 41 deletions(-) create mode 100644 crates/engine/src/board_grid_tests.rs create mode 100644 crates/engine/src/boards/board_grid.rs diff --git a/crates/engine/src/board_grid_tests.rs b/crates/engine/src/board_grid_tests.rs new file mode 100644 index 00000000..ead747d0 --- /dev/null +++ b/crates/engine/src/board_grid_tests.rs @@ -0,0 +1,45 @@ +//! The board grid's settings: kept with the preferences, every value held within its range, and a +//! settings file edited by hand never costing the other preferences. + +use serde_json::json; + +use crate::board_grid::{BoardGrid, GridStyle}; +use crate::{Prefs, Session}; + +#[test] +fn board_grid_sets_and_reads_back_the_preferences() { + let mut s = Session::new(); + let r = s.execute("board.grid", &json!({})).unwrap(); + assert_eq!(r, json!({"style": "dots", "spacing": 24.0, "major": 5, "size": 1.0, "contrast": 1.0})); + let r = s.execute("board.grid", &json!({"style": "graph", "spacing": 8, "major": 10, "contrast": 2.5})).unwrap(); + assert_eq!(r["style"], "graph"); + assert_eq!(s.prefs.board_grid, BoardGrid { style: GridStyle::Graph, spacing: 8.0, major: 10, size: 1.0, contrast: 2.5 }); + // Out of range: the nearest end. + let r = s.execute("board.grid", &json!({"spacing": 1e9, "major": 1, "size": 0, "contrast": 99})).unwrap(); + assert_eq!(r, json!({"style": "graph", "spacing": 512.0, "major": 2, "size": 0.5, "contrast": 4.0})); + // Refused, and nothing changes. + let before = s.prefs.board_grid.clone(); + for p in [json!({"style": "hexagons"}), json!({"style": 3}), json!({"spacing": "wide"}), json!({"contrast": [1]})] { + assert!(s.execute("board.grid", &p).is_err(), "{p}"); + } + assert_eq!(s.prefs.board_grid, before); + // Off, then back to the default. + assert_eq!(s.execute("board.grid", &json!({"style": "None"})).unwrap()["style"], "none"); + s.execute("board.grid", &json!({"reset": true})).unwrap(); + assert_eq!(s.prefs.board_grid, BoardGrid::default()); +} + +#[test] +fn a_saved_board_grid_survives_hand_edits() { + let p = Prefs { board_grid: BoardGrid { style: GridStyle::Lines, spacing: 32.0, major: 4, size: 2.0, contrast: 1.5 }, ..Prefs::default() }; + let back: Prefs = serde_json::from_str(&serde_json::to_string(&p).unwrap()).unwrap(); + assert_eq!(back.board_grid, p.board_grid); + // Unreadable: the default grid, and the other preferences stay. + let back: Prefs = serde_json::from_value(json!({"nudge_small": 3.0, "board_grid": {"style": "zigzag"}})).unwrap(); + assert_eq!((back.nudge_small, back.board_grid), (3.0, BoardGrid::default())); + let back: Prefs = serde_json::from_value(json!({"board_grid": "dots"})).unwrap(); + assert_eq!(back.board_grid, BoardGrid::default()); + // Out of range: clamped; missing fields: their defaults. + let back: Prefs = serde_json::from_value(json!({"board_grid": {"style": "graph", "spacing": -5, "major": 400}})).unwrap(); + assert_eq!(back.board_grid, BoardGrid { style: GridStyle::Graph, spacing: 4.0, major: 12, ..BoardGrid::default() }); +} diff --git a/crates/engine/src/boards.rs b/crates/engine/src/boards.rs index 685c1d70..9c95fb32 100644 --- a/crates/engine/src/boards.rs +++ b/crates/engine/src/boards.rs @@ -1,3 +1,4 @@ pub mod board; +pub mod board_grid; pub mod board_table; pub mod board_templates; diff --git a/crates/engine/src/boards/board_grid.rs b/crates/engine/src/boards/board_grid.rs new file mode 100644 index 00000000..4061e202 --- /dev/null +++ b/crates/engine/src/boards/board_grid.rs @@ -0,0 +1,138 @@ +//! The board canvas's background grid, kept with the preferences: dots (the default), lines, or +//! graph paper (a strong line every few cells over fainter ones, like Japanese 方眼紙 section +//! paper), how far apart, how large and how strong its marks are, or no grid at all. The canvas +//! thins it out as it zooms out, so it never turns into a wash of marks. + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::cmd::CommandSpec; +use crate::params::{boolean, get, num}; +use crate::{Result, Session, bad, spec}; + +/// What the grid draws. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum GridStyle { + #[default] + Dots, + Lines, + /// Lines with every `major`th one strong (section paper). + Graph, + None, +} + +impl GridStyle { + pub const ALL: [GridStyle; 4] = [GridStyle::Dots, GridStyle::Lines, GridStyle::Graph, GridStyle::None]; + + pub fn id(self) -> &'static str { + match self { + GridStyle::Dots => "dots", + GridStyle::Lines => "lines", + GridStyle::Graph => "graph", + GridStyle::None => "none", + } + } + + pub fn from_id(id: &str) -> Option { + GridStyle::ALL.into_iter().find(|s| s.id().eq_ignore_ascii_case(id.trim())) + } +} + +/// The spacing's range (page units between neighbouring marks at 100 %). +pub const SPACING: (f64, f64) = (4.0, 512.0); +/// Graph paper's cells per strong line. +pub const MAJOR: (u32, u32) = (2, 12); +/// The marks' size and strength, relative to the usual (1). +pub const SIZE: (f64, f64) = (0.5, 4.0); +pub const CONTRAST: (f64, f64) = (0.25, 4.0); + +/// The board grid's settings. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct BoardGrid { + pub style: GridStyle, + /// Page units between neighbouring marks at 100 % (zoomed out, every second, fourth… stays). + pub spacing: f64, + /// Graph paper: a strong line every `major` cells. + pub major: u32, + /// How large the marks are: 1 is about a point and a half across for dots, a point for lines. + pub size: f64, + /// How strong the marks are against the page: 1 is the usual, subtle grid. + pub contrast: f64, +} + +impl Default for BoardGrid { + fn default() -> Self { + Self { style: GridStyle::Dots, spacing: 24.0, major: 5, size: 1.0, contrast: 1.0 } + } +} + +/// `v` kept within `range`; `d` when it is not a finite number. +fn within(v: f64, (lo, hi): (f64, f64), d: f64) -> f64 { + if v.is_finite() { v.clamp(lo, hi) } else { d } +} + +impl BoardGrid { + /// Every value back within its range (a settings file edited by hand). + pub fn sanitized(mut self) -> Self { + let d = Self::default(); + self.spacing = within(self.spacing, SPACING, d.spacing); + self.major = self.major.clamp(MAJOR.0, MAJOR.1); + self.size = within(self.size, SIZE, d.size); + self.contrast = within(self.contrast, CONTRAST, d.contrast); + self + } + + pub fn to_json(&self) -> Value { + json!({"style": self.style.id(), "spacing": self.spacing, "major": self.major, "size": self.size, "contrast": self.contrast}) + } +} + +/// Reads the saved grid: anything unreadable is the default grid and values out of range are +/// clamped, so a settings file edited by hand never costs the other preferences. +pub(crate) fn read<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result { + Ok(serde_json::from_value::(Value::deserialize(d)?).unwrap_or_default().sanitized()) +} + +const CMD: &str = "board.grid"; + +/// A number param of `board.grid`: `None` when omitted, an error when not a finite number. +fn number(p: &Value, key: &str) -> Result> { + match get(p, key) { + None | Some(Value::Null) => Ok(None), + Some(_) => num(p, key).filter(|v| v.is_finite()).map(Some).ok_or_else(|| bad(CMD, format!("`{key}` must be a number"))), + } +} + +fn grid_cmd(s: &mut Session, p: &Value) -> Result { + let mut g = if boolean(p, "reset") == Some(true) { BoardGrid::default() } else { s.prefs.board_grid.clone() }; + if let Some(v) = get(p, "style").filter(|v| !v.is_null()) { + let id = v.as_str().unwrap_or_default(); + g.style = GridStyle::from_id(id).ok_or_else(|| bad(CMD, "`style` is dots, lines, graph or none"))?; + } + if let Some(v) = number(p, "spacing")? { + g.spacing = v; + } + if let Some(v) = number(p, "major")? { + g.major = v.round().clamp(f64::from(MAJOR.0), f64::from(MAJOR.1)) as u32; + } + if let Some(v) = number(p, "size")? { + g.size = v; + } + if let Some(v) = number(p, "contrast")? { + g.contrast = v; + } + s.prefs.board_grid = g.sanitized(); + Ok(s.prefs.board_grid.to_json()) +} + +pub fn specs() -> Vec { + vec![spec!( + "board.grid", + "Board grid", + "{style?: dots|lines|graph|none (graph: section paper, a strong line every `major` cells), spacing? (4–512 page units at 100 %), major? (2–12), size? (0.5–4, 1 the usual), contrast? (0.25–4, 1 the usual), reset? (back to the default first); omit all: just read it} → {style, spacing, major, size, contrast} the board canvas's background grid, kept with the preferences; zoomed out it thins out", + false, + grid_cmd + )] +} diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index c046cd19..f6584d9e 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -117,6 +117,7 @@ fn registry() -> &'static Registry { list.extend(crate::textfit::specs()); list.extend(crate::board::specs()); list.extend(facilitate::specs()); + list.extend(crate::board_grid::specs()); list.extend(crate::board_table::specs()); list.extend(crate::board_templates::specs()); let mut index = HashMap::new(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 872f3cd3..f6305cf8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -39,6 +39,7 @@ pub use automation::file_access; pub use automation::inspect; pub use automation::params; pub use boards::board; +pub use boards::board_grid; pub use boards::board_table; pub use boards::board_templates; pub use canvas::al_keys; @@ -193,6 +194,8 @@ mod binding_lists_tests; #[cfg(test)] mod board_connector_tests; #[cfg(test)] +mod board_grid_tests; +#[cfg(test)] mod board_tables_tests; #[cfg(test)] mod board_tests; diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 74913df6..89668a1a 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -99,6 +99,9 @@ pub struct Prefs { /// files opened or made later start with them. Missing: the toggle's default. #[serde(skip_serializing_if = "BTreeMap::is_empty")] pub view: BTreeMap, + /// The board canvas's background grid (`board.grid`). + #[serde(deserialize_with = "crate::board_grid::read")] + pub board_grid: crate::board_grid::BoardGrid, /// Recently run actions (the Actions palette's Recents), newest first, at most /// [`MAX_RECENT_ACTIONS`]. Entries are `command:`, `plugin:/` or /// `widget:`. @@ -141,6 +144,7 @@ impl Default for Prefs { home_sort: "modified".into(), home_order: "desc".into(), view: BTreeMap::new(), + board_grid: Default::default(), recent_actions: Vec::new(), } } diff --git a/crates/render/src/dots.rs b/crates/render/src/dots.rs index 980ea85a..b13cbdd5 100644 --- a/crates/render/src/dots.rs +++ b/crates/render/src/dots.rs @@ -1,22 +1,27 @@ -//! A dot grid on the page, under its layers (the board canvas): square dots on the device-pixel -//! grid, blended into the image right after the page colour so the layers cover them. Each dot's -//! pixels are computed from its page position alone, so bands and partial renders line up. Nothing -//! is allocated per dot. +//! A grid on the page, under its layers (the board canvas): square dots or lines on the +//! device-pixel grid, blended into the image right after the page colour so the layers cover them. +//! Each mark's pixels are computed from its page position alone, so bands and partial renders line +//! up. Nothing is allocated per mark. use figcraft_color::Color; use tiny_skia::Pixmap; use crate::View; -/// Dots every `step` page units from the page origin, `size` page units square (from one to 64 -/// device pixels): those on every second line both ways in `color`, the others (the ones a grid -/// twice as coarse leaves out) in `faded`. +/// Dots (or lines both ways) every `step` page units from the page origin, `size` page units +/// across (from one to 64 device pixels): those on every `major`th line both ways (every line of +/// the `major`th, for lines) in `color`, the others (the ones a grid `major` times as coarse leaves +/// out) in `faded`. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Dots { pub step: f64, pub size: f64, pub color: Color, pub faded: Color, + /// Lines instead of dots. + pub lines: bool, + /// Every how many steps a mark is strong (2 or more; less reads as 2). + pub major: u32, } /// The most dots one render draws: a step far too small for the view draws none. @@ -43,31 +48,99 @@ pub(crate) fn draw(pix: &mut Pixmap, view: &View, d: &Dots) { if !(count.is_finite() && count <= MAX_DOTS) { return; } - // Each dot on the device pixel nearest its point, from the view's top-left in device pixels. + // Each mark on the device pixel nearest its point, from the view's top-left in device pixels. let (ox, oy) = (x0 * s, y0 * s); let side = ((d.size * s).round() as i64).clamp(1, 64); let lead = side / 2; - let (strong, faded) = (premultiplied(d.color), premultiplied(d.faded)); + let colors = [premultiplied(d.faded), premultiplied(d.color)]; + let major = i64::from(d.major.max(2)); + let strong = |k: i64| k.rem_euclid(major) == 0; + // The device-pixel span of the mark on line `k` along an axis `len` pixels long from `o`. + let span = |k: i64, o: f64, len: i64| { + let a = ((k as f64 * d.step * s - o).round() as i64).saturating_sub(lead); + (a.max(0), a.saturating_add(side).min(len)) + }; let row = w as usize * 4; let data = pix.data_mut(); + if d.lines { + lines( + data, + row, + (w, h), + [(i0 as i64, i1 as i64), (j0 as i64, j1 as i64)], + &|k, vertical| { + let (o, len) = if vertical { (ox, w) } else { (oy, h) }; + (span(k, o, len), strong(k)) + }, + colors, + ); + return; + } for j in j0 as i64..=j1 as i64 { - let top = ((j as f64 * d.step * s - oy).round() as i64).saturating_sub(lead); - let (ya, yb) = (top.max(0), top.saturating_add(side).min(h)); + let (ya, yb) = span(j, oy, h); if ya >= yb { continue; } for i in i0 as i64..=i1 as i64 { - let left = ((i as f64 * d.step * s - ox).round() as i64).saturating_sub(lead); - let (xa, xb) = (left.max(0), left.saturating_add(side).min(w)); + let (xa, xb) = span(i, ox, w); if xa >= xb { continue; } - let rgba = if i % 2 == 0 && j % 2 == 0 { strong } else { faded }; + let rgba = colors[usize::from(strong(i) && strong(j))]; blend_square(data, row, (xa, xb), (ya, yb), rgba); } } } +/// Which line covers each device pixel along an axis: 0 none, 1 a faded one, 2 a strong one. +type Cover = Vec; + +/// Draw lines both ways: the vertical ones `ks[0]`, the horizontal ones `ks[1]`, each line's span +/// and strength from `mark(k, vertical)`. A crossing is blended once, in the stronger colour. +fn lines( + data: &mut [u8], + row: usize, + (w, h): (i64, i64), + ks: [(i64, i64); 2], + mark: &dyn Fn(i64, bool) -> ((i64, i64), bool), + colors: [[u32; 4]; 2], +) { + let cover = |(k0, k1): (i64, i64), vertical: bool, len: i64| { + let mut c: Cover = vec![0; len.max(0) as usize]; + for k in k0..=k1 { + let ((a, b), strong) = mark(k, vertical); + for v in c.get_mut(a.max(0) as usize..b.max(a).max(0) as usize).into_iter().flatten() { + *v = (*v).max(1 + u8::from(strong)); + } + } + c + }; + let (cols, rows) = (cover(ks[0], true, w), cover(ks[1], false, h)); + // The columns' runs, so a row without a line only visits the vertical lines. + let mut runs: Vec<(usize, usize, u8)> = Vec::new(); + for (x, &c) in cols.iter().enumerate() { + match runs.last_mut() { + Some(r) if r.1 == x && r.2 == c => r.1 = x + 1, + _ if c > 0 => runs.push((x, x + 1, c)), + _ => {} + } + } + for (y, &rc) in rows.iter().enumerate() { + let start = y * row; + if rc == 0 { + for &(a, b, c) in &runs { + let rgba = colors[usize::from(c.saturating_sub(1).min(1))]; + blend_square(data, row, (a as i64, b as i64), (y as i64, y as i64 + 1), rgba); + } + continue; + } + let Some(line) = data.get_mut(start..start + w as usize * 4) else { continue }; + for (px, &cc) in line.chunks_exact_mut(4).zip(&cols) { + blend_px(px, colors[usize::from(rc.max(cc).saturating_sub(1).min(1))]); + } + } +} + /// Blend premultiplied `rgba` source-over into the pixels `xs` × `ys` of `data`, rows `row` bytes /// long. fn blend_square(data: &mut [u8], row: usize, (xa, xb): (i64, i64), (ya, yb): (i64, i64), rgba: [u32; 4]) { @@ -79,9 +152,20 @@ fn blend_square(data: &mut [u8], row: usize, (xa, xb): (i64, i64), (ya, yb): (i6 let start = y as usize * row; let Some(line) = data.get_mut(start + xa as usize * 4..start + xb as usize * 4) else { continue }; for px in line.chunks_exact_mut(4) { - for (dst, src) in px.iter_mut().zip(rgba) { - *dst = (src + u32::from(*dst) * keep / 255).min(255) as u8; - } + blend_with(px, rgba, keep); } } } + +/// Blend premultiplied `rgba` source-over into the pixel `px`. +fn blend_px(px: &mut [u8], rgba: [u32; 4]) { + if rgba[3] > 0 { + blend_with(px, rgba, 255 - rgba[3]); + } +} + +fn blend_with(px: &mut [u8], rgba: [u32; 4], keep: u32) { + for (dst, src) in px.iter_mut().zip(rgba) { + *dst = (src + u32::from(*dst) * keep / 255).min(255) as u8; + } +} diff --git a/crates/render/src/dots_tests.rs b/crates/render/src/dots_tests.rs index 3adcbeab..53d05206 100644 --- a/crates/render/src/dots_tests.rs +++ b/crates/render/src/dots_tests.rs @@ -10,7 +10,7 @@ use figcraft_doc::{Document, NodeKind}; use crate::{Dots, Options, Retained, View, render_view, render_view_single}; fn dots() -> Dots { - Dots { step: 24.0, size: 2.0, color: Color::new(0.0, 0.0, 0.0, 0.5), faded: Color::new(0.0, 0.0, 0.0, 0.25) } + Dots { step: 24.0, size: 2.0, color: Color::new(0.0, 0.0, 0.0, 0.5), faded: Color::new(0.0, 0.0, 0.0, 0.25), lines: false, major: 2 } } fn page_with_square() -> (Document, figcraft_doc::NodeId) { @@ -85,4 +85,52 @@ fn dots_it_cant_draw_draw_nothing() { } let far = View { origin: (f64::MAX / 2.0, -f64::MAX / 2.0), scale: 1e10, width: 16, height: 16 }; let _ = render_view_single(&d, page, &far, &Options { dots: Some(dots()), ..Default::default() }); + for major in [0, 1, u32::MAX] { + for lines in [false, true] { + let opts = Options { dots: Some(Dots { major, lines, ..dots() }), ..Default::default() }; + let _ = render_view_single(&d, page, &view, &opts); + let _ = render_view_single(&d, page, &far, &opts); + } + } +} + +#[test] +fn lines_run_both_ways_with_crossings_blended_once() { + let (d, page) = page_with_square(); + let view = View { origin: (0.0, 0.0), scale: 1.0, width: 200, height: 200 }; + let lines = Dots { lines: true, size: 1.0, ..dots() }; + let pix = render_view_single(&d, page, &view, &Options { dots: Some(lines), ..Default::default() }).unwrap(); + let plain = render_view_single(&d, page, &view, &Options::default()).unwrap(); + // Line 0 is strong, line 1 (at 24) faded, line 2 (at 48) strong again; between them, the page. + let (strong, faded, empty) = (rgba(&pix, 0, 12), rgba(&pix, 24, 12), rgba(&pix, 12, 12)); + assert_eq!(empty, rgba(&plain, 12, 12)); + assert!(strong[0] < faded[0] && faded[0] < empty[0], "{strong:?} {faded:?} {empty:?}"); + assert_eq!(rgba(&pix, 12, 0), strong, "a horizontal line as strong as a vertical one"); + // A crossing is no darker than the stronger of its lines. + assert_eq!(rgba(&pix, 0, 0), strong); + assert_eq!(rgba(&pix, 24, 0), strong, "a faded line crossing a strong one"); + assert_eq!(rgba(&pix, 24, 24), faded); + // The square covers the lines under it. + assert_eq!(rgba(&pix, 72, 72), rgba(&plain, 72, 72)); + // Banded or not, the same pixels. + let wide = View { origin: (-212.0, -140.0), scale: 1.5, width: 900, height: 700 }; + let opts = Options { dots: Some(lines), ..Default::default() }; + assert_eq!(render_view_single(&d, page, &wide, &opts).unwrap().data(), render_view(&d, page, &wide, &opts).unwrap().data()); +} + +#[test] +fn graph_paper_is_strong_every_major_line() { + let (d, page) = page_with_square(); + let view = View { origin: (-200.0, -200.0), scale: 1.0, width: 160, height: 160 }; + let graph = Dots { lines: true, size: 1.0, major: 5, step: 10.0, ..dots() }; + let pix = render_view_single(&d, page, &view, &Options { dots: Some(graph), ..Default::default() }).unwrap(); + let at = |x: i32, y: i32| rgba(&pix, (x + 200) as u32, (y + 200) as u32); + let strong = at(-150, -195); + for x in [-190, -180, -170, -160, -140, -130, -120, -110] { + assert_ne!(at(x, -195), strong, "a faded line at {x}"); + assert_eq!(at(x, -195), at(-190, -195)); + } + for x in [-200, -100, -50] { + assert_eq!(at(x, -195), strong, "a strong line at {x}"); + } } diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 3450db8e..2f22e76c 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -183,6 +183,8 @@ name = "English" "Blend mode" = "Blend mode" "Blue" = "Blue" "Blur" = "Blur" +"Board grid" = "Board grid" +"Board grid settings…" = "Board grid settings…" "Boolean" = "Boolean" "Boolean groups" = "Boolean groups" "Both directions" = "Both directions" @@ -613,6 +615,7 @@ name = "English" "Go to {name}" = "Go to {name}" "Grab bag" = "Grab bag" "Gradient" = "Gradient" +"Graph paper" = "Graph paper" "Gray" = "Gray" "Green" = "Green" "Greyscale components and two example screens to sketch a flow with" = "Greyscale components and two example screens to sketch a flow with" @@ -1198,6 +1201,7 @@ name = "English" "Reset adjustments" = "Reset adjustments" "Reset all changes" = "Reset all changes" "Reset all overrides on this layer" = "Reset all overrides on this layer" +"Reset grid" = "Reset grid" "Reset overrides" = "Reset overrides" "Reset slot" = "Reset slot" "Reset this override" = "Reset this override" @@ -1425,6 +1429,7 @@ name = "English" "Stroke style" = "Stroke style" "Stroke weight" = "Stroke weight" "Strokes" = "Strokes" +"Strong line every" = "Strong line every" "Style" = "Style" "Styles" = "Styles" "Stylistic set 1" = "Stylistic set 1" diff --git a/crates/ui-egui/src/board_ui_tests.rs b/crates/ui-egui/src/board_ui_tests.rs index e4758ca7..f1216d77 100644 --- a/crates/ui-egui/src/board_ui_tests.rs +++ b/crates/ui-egui/src/board_ui_tests.rs @@ -242,7 +242,7 @@ fn a_board_page_has_a_dot_grid_under_its_layers_thinned_out_zoomed_out() { let fill = u.app.session.doc().unwrap().node(sticky).unwrap().fills[0].solid_color().unwrap().to_rgba8(); assert_eq!(at(2.0 * step, 2.0 * step), [fill[0], fill[1], fill[2]], "the sticky covers the dot under it"); // Zoomed out, the dots space out: every second, fourth… stays, the next to go fading. - use crate::board_bar::dot_spacing; + let dot_spacing = |z: f64| crate::board_bar::grid_spacing(z, 24.0, 2, 14.0); assert_eq!(dot_spacing(1.0), Some((24.0, ((24.0 - 14.0) / 14.0) as f32))); assert_eq!(dot_spacing(0.25).map(|d| d.0), Some(96.0)); assert_eq!(dot_spacing(4.0), Some((24.0, 1.0))); @@ -271,3 +271,40 @@ fn a_selected_connector_shows_its_ends_as_handles() { assert!(ring, "a handle at {end:?}"); } } + +#[test] +fn the_board_grid_follows_its_settings() { + let mut u = board(); + u.app.session.execute("view.zoom100", &json!({})).unwrap(); + u.frame(vec![]); + let t = crate::theme::Tokens::get(&u.ctx); + let bg = egui::Color32::from_rgb(240, 240, 240); + let usual = crate::board_bar::dots(&u.app, bg, &t).unwrap(); + assert!(!usual.lines && usual.major == 2); + // Lines, as fine at any zoom. + u.app.run("board.grid", json!({"style": "lines"})); + let lines = crate::board_bar::dots(&u.app, bg, &t).unwrap(); + assert!(lines.lines && lines.major == 2 && lines.color.a < usual.color.a, "lines start fainter than dots"); + // Graph paper: a strong line every 5 cells of 8, the cells in between fainter even zoomed in. + u.app.run("board.grid", json!({"style": "graph", "spacing": 8, "major": 5})); + u.app.session.ds_mut().unwrap().view.zoom = 4.0; + let graph = crate::board_bar::dots(&u.app, bg, &t).unwrap(); + assert_eq!((graph.lines, graph.major, graph.step), (true, 5, 8.0)); + assert!(graph.faded.a < graph.color.a); + // Zoomed out, the fine cells go and the strong lines stay: the step grows by fives. + u.app.session.ds_mut().unwrap().view.zoom = 0.25; + let far = crate::board_bar::dots(&u.app, bg, &t).unwrap(); + assert_eq!(far.step, 8.0 * 5.0, "40 × 0.25 = 10 points apart, the closest lines come"); + assert_eq!(far.faded.a, 0.0, "the cells the next step drops have faded out"); + // More contrast: stronger marks; larger: larger. + u.app.run("board.grid", json!({"style": "dots", "contrast": 3, "size": 2})); + let strong = crate::board_bar::dots(&u.app, bg, &t).unwrap(); + assert!(strong.color.a > usual.color.a * 2.5 && strong.size > usual.size * 0.25 * 1.5, "{strong:?}"); + // None: no grid. + u.app.run("board.grid", json!({"style": "none"})); + assert!(crate::board_bar::dots(&u.app, bg, &t).is_none()); + use crate::board_bar::grid_spacing; + assert_eq!(grid_spacing(1.0, 24.0, 5, 0.0), None); + assert_eq!(grid_spacing(1e-320, 512.0, 2, 14.0), None, "no finite step that far out"); + assert!(grid_spacing(1e-300, 4.0, 12, 10.0).is_some_and(|(step, _)| step.is_finite())); +} diff --git a/crates/ui-egui/src/canvas/board_bar.rs b/crates/ui-egui/src/canvas/board_bar.rs index c1a76a4d..f7add740 100644 --- a/crates/ui-egui/src/canvas/board_bar.rs +++ b/crates/ui-egui/src/canvas/board_bar.rs @@ -5,6 +5,7 @@ use egui::{Color32, CornerRadius, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use figcraft_doc::{BOARD_COLORS, BoardItem, BoardShape, NodeKind, Routing, Stamp, StrokeCap}; use figcraft_engine::board::{BoardTool, TEXT_SIZES}; +use figcraft_engine::board_grid::GridStyle; use serde_json::{Value, json}; use crate::FigApp; @@ -287,46 +288,123 @@ pub(crate) fn divider(ui: &Ui, t: &Tokens, x: f32, cy: f32) -> f32 { x + 8.0 } -/// The dot grid's spacing on the page at 100%, and the closest its dots come on screen (points): -/// zoomed out, every second, fourth… dot stays, the ones going fading out first. -const DOT_STEP: f64 = 24.0; +/// The closest the grid's marks come on screen (points): zoomed out, every second, fourth… mark +/// stays (graph paper: every strong line), the ones going fading out first. Lines read at a +/// smaller gap than dots. const DOT_MIN_GAP: f64 = 14.0; +const LINE_MIN_GAP: f64 = 10.0; -/// A board page's dot grid on its background `bg`, for the canvas render to draw under the -/// layers: dark dots on a light page, light ones on a dark page, in the theme's ink, about a point -/// and a half across on screen. `None` on design files. +/// A board page's grid on its background `bg` as the preferences set it (`board.grid`), for the +/// canvas render to draw under the layers: dark marks on a light page, light ones on a dark page, +/// in the theme's ink. `None` on design files and with the grid off. pub fn dots(app: &FigApp, bg: Color32, t: &Tokens) -> Option { let ds = app.session.ds().ok()?; if !figcraft_engine::board::is_board(&ds.doc) { return None; } + let g = app.session.prefs.board_grid.clone().sanitized(); + let lines = match g.style { + GridStyle::None => return None, + GridStyle::Dots => false, + GridStyle::Lines | GridStyle::Graph => true, + }; + // Dots and plain lines thin out by halves; graph paper keeps its strong lines. + let major = if g.style == GridStyle::Graph { g.major } else { 2 }; let zoom = ds.view.zoom; - let (step, fade) = dot_spacing(zoom)?; + let min_gap = if lines { LINE_MIN_GAP } else { DOT_MIN_GAP }; + let (step, fade) = grid_spacing(zoom, g.spacing, major, min_gap)?; let dark_page = 0.2126 * f32::from(bg.r()) + 0.7152 * f32::from(bg.g()) + 0.0722 * f32::from(bg.b()) < 128.0; let ink = if dark_page == t.dark { t.text } else { t.bg }; - let alpha = if dark_page { 0.22 } else { 0.18 }; + // Lines cover more of the page than dots, so they start fainter. + let base = if dark_page { 0.22 } else { 0.18 } * if lines { 0.6 } else { 1.0 }; + let alpha = (base * g.contrast as f32).clamp(0.0, 1.0); + // Graph paper's in-between lines stay fainter than its strong ones even zoomed in. + let faded = alpha * fade * if g.style == GridStyle::Graph { 0.5 } else { 1.0 }; let color = |a: f32| figcraft_color::Color::from_rgba8(ink.r(), ink.g(), ink.b(), (a * 255.0).round() as u8); - let size = (1.5 * zoom.sqrt()).clamp(1.0, 3.0) / zoom; - Some(figcraft_render::Dots { step, size, color: color(alpha), faded: color(alpha * fade) }) + // Dots grow a little zoomed in, and never shrink below their size at 100 % zoomed out (where + // a pixel-sized dot all but disappears). + let k = g.size; + let size = if lines { k.max(0.5) / zoom } else { (1.5 * k * zoom.max(1.0).sqrt()).min(3.0 * k.max(1.0)) / zoom }; + Some(figcraft_render::Dots { step, size, color: color(alpha), faded: color(faded), lines, major }) } -/// The dot grid's spacing on the page at `zoom` (doubled until the dots are far enough apart on -/// screen), and how strongly the dots the next doubling drops still show (0 to 1). -pub(crate) fn dot_spacing(zoom: f64) -> Option<(f64, f32)> { - if !(zoom.is_finite() && zoom > 0.0) { +/// The grid's spacing on the page at `zoom`, starting from `base` and multiplied by `major` until +/// the marks are at least `min_gap` apart on screen, and how strongly the marks the next step +/// drops still show (0 to 1). +pub(crate) fn grid_spacing(zoom: f64, base: f64, major: u32, min_gap: f64) -> Option<(f64, f32)> { + let ok = |v: f64| v.is_finite() && v > 0.0; + if !(ok(zoom) && ok(base) && ok(min_gap)) { return None; } - // Bounded: past about 1000 doublings the step is no longer finite, and there are no dots. - let mut step = DOT_STEP; + let factor = f64::from(major.max(2)); + // Bounded: past about 1000 multiplications the step is no longer finite, and there is no grid. + let mut step = base; for _ in 0..1100 { - if step * zoom >= DOT_MIN_GAP { + if step * zoom >= min_gap { break; } - step *= 2.0; + step *= factor; } let gap = step * zoom; - // From one to two minimum gaps apart, the in-between dots fade in. - (gap.is_finite() && gap >= DOT_MIN_GAP).then(|| (step, ((gap - DOT_MIN_GAP) / DOT_MIN_GAP).clamp(0.0, 1.0) as f32)) + // From one to two minimum gaps apart, the in-between marks fade in. + (gap.is_finite() && gap >= min_gap).then(|| (step, ((gap - min_gap) / min_gap).clamp(0.0, 1.0) as f32)) +} + +/// The Preferences dialog's board grid settings (`board.grid`): its style, then (with a grid) +/// how far apart, how large and how strong its marks are, and graph paper's cells per strong line. +pub(crate) fn grid_settings(app: &mut FigApp, ui: &mut Ui) { + let g = app.session.prefs.board_grid.clone().sanitized(); + let mut set = None; + ui.horizontal(|ui| { + ui.label(tr("Board grid")); + for style in GridStyle::ALL { + let label = match style { + GridStyle::Dots => tr("Dots"), + GridStyle::Lines => tr("Lines"), + GridStyle::Graph => tr("Graph paper"), + GridStyle::None => tr("None"), + }; + if ui.selectable_label(g.style == style, label).clicked() { + set = Some(json!({"style": style.id()})); + } + } + }); + if g.style != GridStyle::None { + ui.horizontal(|ui| { + let (mut spacing, mut major) = (g.spacing, g.major); + ui.label(tr("Spacing")); + let (lo, hi) = figcraft_engine::board_grid::SPACING; + if ui.add(egui::DragValue::new(&mut spacing).range(lo..=hi)).changed() { + set = Some(json!({"spacing": spacing})); + } + if g.style == GridStyle::Graph { + ui.label(tr("Strong line every")); + let (lo, hi) = figcraft_engine::board_grid::MAJOR; + if ui.add(egui::DragValue::new(&mut major).range(lo..=hi)).changed() { + set = Some(json!({"major": major})); + } + } + }); + ui.horizontal(|ui| { + let (mut size, mut contrast) = (g.size, g.contrast); + ui.label(tr("Size")); + let (lo, hi) = figcraft_engine::board_grid::SIZE; + if ui.add(egui::Slider::new(&mut size, lo..=hi).max_decimals(2)).changed() { + set = Some(json!({"size": size})); + } + ui.label(tr("Contrast")); + let (lo, hi) = figcraft_engine::board_grid::CONTRAST; + if ui.add(egui::Slider::new(&mut contrast, lo..=hi).max_decimals(2)).changed() { + set = Some(json!({"contrast": contrast})); + } + }); + if g != Default::default() && crate::widgets::button(ui, tr("Reset grid"), false, true).clicked() { + set = Some(json!({"reset": true})); + } + } + if let Some(p) = set { + app.run("board.grid", p); + } } /// The name of the person who placed the stamp under the pointer, beside it. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b3e1461a..c6e5829f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -300,6 +300,8 @@ pub fn show(app: &mut FigApp, ctx: &Context) { ui.add_space(8.0); interface_size(app, ui, prefs.ui_scale); ui.add_space(8.0); + crate::board_bar::grid_settings(app, ui); + ui.add_space(8.0); ui.horizontal(|ui| { ui.label(tr("Theme")); for (l, c) in [(tr_noop("Light"), "view.themeLight"), (tr_noop("Dark"), "view.themeDark"), (tr_noop("System"), "view.themeSystem")] { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 76f220f4..f10283b3 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -554,6 +554,10 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { { entries[at] = (tr("Go to main component"), "component.goToMain"); } + // On a board, the background grid's settings (Preferences ▸ Board grid). + if !has_sel && app.session.doc().is_ok_and(figcraft_engine::board::is_board) { + entries.extend([("-", ""), (tr("Board grid settings…"), "app.preferences")]); + } // A right-clicked ruler guide (selected, with no layers) can be removed. if !has_sel && app.session.ds().is_ok_and(|d| d.selected_guide().is_some()) { entries.splice(0..0, [(tr("Remove guide"), "guide.remove"), ("-", "")]); diff --git a/docs/boards-parity.md b/docs/boards-parity.md index 383a6933..82c32367 100644 --- a/docs/boards-parity.md +++ b/docs/boards-parity.md @@ -1,6 +1,6 @@ # Boards parity -> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-29 · **Change:** minor (the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); templates and tables; facilitation: a shared timer and voting sessions; the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre) +> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-29 · **Change:** minor (board grid settings: dots, lines, graph paper or none, spacing, size and contrast; the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); templates and tables; facilitation: a shared timer and voting sessions; the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre) **Breadth ~47%, ready ~29% (estimated, erring low).** A board is a FigCraft file of its own kind (`file.newBoard`) with a dot grid on the page, sticky notes, shapes with text, connectors that @@ -56,13 +56,14 @@ all end with the same geometry. | Washi tape, code blocks, image cropping on boards | missing | images can be placed as in design files | | Audio and huddles | missing | | | AI features (summaries, sorting stickies, generating boards) | missing | | -| Dot grid on the page | done | the canvas render draws it after the page colour and before the layers (`figcraft_render::Dots`), so objects cover it; 24 units apart, doubling when zoomed out until the dots are at least 14 points apart, the dots the next doubling drops fading out first; dark dots on a light page, light ones on a dark page, from the theme's ink; written straight into the image, no allocation per dot; never in exports | +| Grid on the page | done | the canvas render draws it after the page colour and before the layers (`figcraft_render::Dots`), so objects cover it. Dots by default, or lines, graph paper (a strong line every 2–12 cells over fainter ones, like Japanese section paper) or none; spacing (4–512), mark size and contrast are settings kept with the preferences (`board.grid`; Preferences ▸ Board grid, or right-click the board ▸ Board grid settings…). Zoomed out it thins out (dots and lines by halves, graph paper down to its strong lines) until the marks are at least 14 points apart (10 for lines), the marks the next step drops fading out first; dots never shrink below their size at 100 %. Dark marks on a light page, light ones on a dark page, from the theme's ink; written straight into the image, no allocation per mark, a crossing of two lines blended once; never in exports. FigCraft's own settings: the incumbent's board grid has none | | `.jam` import and export | missing | see [`file-format-parity.md`](file-format-parity.md) | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M12: board grid settings (`board.grid`): dots, lines, graph paper or none, with spacing, size and contrast, in Preferences and the board's right-click menu; zoomed-out dots no longer shrink to a pixel. Numbers unchanged (beyond the incumbent's behaviour) | | 2026-10-29 | minor | M12, #1387 (connector tool): while a connector is dragged out with the connector tool, the canvas shows the connector itself as it will be made (its straight, elbow or curved route, line, weight and arrowhead, built by the same code as `board.connector`), not a thin line; the object under the pointer is outlined with its four attach points (the middles of its sides), the one the end goes to ringed; within 10 px of one the end keeps that side (`fromSide`/`toSide`, and `side` when re-attaching). Hovering with the tool shows the attach points too. Escape drops the preview; releasing makes the connector in one undo step. Tests: `board_connector_tests`. Numbers unchanged | | 2026-10-24 | minor | M12: the dot grid on board pages (a render pass under the layers), dragging a selected connector's end onto another object or empty canvas (`board.reattach`), curved connectors, and elbows that go around the objects they join. Breadth ~35% → ~37%, ready ~20% → ~21%, ~30–60 h → ~28–56 h | | 2026-10-24 | minor | Facilitation (M12): a shared timer and voting sessions. Breadth ~35% → ~40%, ready ~20% → ~24%, ~30–60 h → ~26–54 h | From 3af5d8a5348242617d864aa73ddecf5e68d2a25a Mon Sep 17 00:00:00 2001 From: BFlat Date: Thu, 29 Oct 2026 22:07:06 +0700 Subject: [PATCH 41/63] M9: comment.list reports the names each message mentions (#1415) (#1534) Every message (the first and each reply) gets `mentions`: {name, known, you} for each @mention, by the rule the thread view highlights with (figcraft_doc::mentions, now shared with mention_ranges). Names resolve against the file's people and you; unknown names are kept with known: false; a@b.com mentions nobody. --- ROADMAP.md | 3 +- crates/doc/src/comments.rs | 23 +++++++---- crates/engine/src/cmd/comment.rs | 36 +++++++++++++---- crates/engine/src/comment_mentions_tests.rs | 44 +++++++++++++++++++++ crates/engine/src/lib.rs | 2 + crates/mcp/src/prompts.rs | 2 +- docs/collaboration-parity.md | 5 ++- docs/mcp.md | 7 +++- docs/roadmap.md | 3 +- 9 files changed, 103 insertions(+), 22 deletions(-) create mode 100644 crates/engine/src/comment_mentions_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 5e6eea88..df1d625c 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-29 · **Change:** minor (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-10-29 · **Change:** minor (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 @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M9, #1415: `comment.list` (CLI, MCP `run_command`, the `figcraft://comments` resource) gives every message, the first and each reply, its `mentions`: `{name, known, you}` for each name it @mentions, by the rule the thread view highlights them with (`figcraft_doc::mentions`, which the highlighting now shares). A name resolves against the file's people and you, as the file spells it. An unknown name is reported with `known: false`. `a@b.com` mentions nobody. Filtering threads by a mentioned name and listing mentioned names in `comment.people` stay open. Numbers unchanged | | 2026-10-29 | minor | M8, #1061: a mask group's pixmaps (the mask, its own layer, the content and the pixels it goes onto) cover only where the mask or its content can paint (effects included) within the view, rounded out to whole pixels, instead of the whole view: a 40 × 40 mask on a 3000 × 2000 view held 96 MB, now 61.5 kB, drawing the same pixels. Isolated layers (effects, opacity, masks, clipping frames) also share one budget of 2 GiB held at once per render thread (`figcraft_render::layer_budget`); a layer past it is left out with a warning, so 64 nested full-view layers no longer ask for tens of GB. Tests: `mask_bounds_tests`. Numbers unchanged | | 2026-10-29 | minor | M2: components can't contain their own instances (#1084). Any edit that would make a component hold an instance of itself, directly or through other components (an instance-swap property's current value included), is refused with the loop named: creating, placing, swapping, switching variants, moving, pasting and instance-swap values. Each variant counts as a component of its own, so a variant holding a sibling variant's instance is fine unless that one leads back to it (`components::check_component_cycles`, beside the instance structure check); a dropped instance lands on the page instead. A file holding such a loop (opened or imported) has one instance per loop detached, with a note; a loop merged in a live session is reported, and a rebuild never expands an instance inside a copy of itself, so its copies stop growing (12 rebuilds of a two-component loop: 25 → 113 layers before, 25 throughout now). Tests: `component_cycle_tests`. Numbers unchanged | | 2026-10-29 | minor | Which copy is which (M8, #995): `edit.duplicate` with `map: true` also returns `map`, each original layer's id, descendants included (nested frames, groups, instances and their sublayers), paired with its copy's id, so a batch can edit inside a copy through a step reference (`"field": "map/6"`) instead of inspecting it. Without `map` the result is unchanged. Regression tests in `crates/engine/src/duplicate_map_tests.rs`. Numbers unchanged | diff --git a/crates/doc/src/comments.rs b/crates/doc/src/comments.rs index c350069b..4e43b84d 100644 --- a/crates/doc/src/comments.rs +++ b/crates/doc/src/comments.rs @@ -183,10 +183,16 @@ impl Comment { } } -/// The byte ranges of `@Name` mentions of `people` in `text` (names match ignoring ASCII case; the -/// longest name wins; a mention starts the text or follows a non-alphanumeric character and ends -/// before one). +/// The byte ranges of `@Name` mentions of `people` in `text` (see [`mentions`]). pub fn mention_ranges(text: &str, people: &[&str]) -> Vec> { + mentions(text, people).into_iter().filter(|(_, who)| who.is_some()).map(|(r, _)| r).collect() +} + +/// Every `@` mention in `text`: its byte range and the one of `people` it names (names match +/// ignoring ASCII case; the longest name wins), or `None` for a name nobody in `people` has (then +/// the letters and digits after the `@`). A mention starts the text or follows a non-alphanumeric +/// character and ends before one, so `a@b.com` mentions nobody. +pub fn mentions<'p>(text: &str, people: &[&'p str]) -> Vec<(Range, Option<&'p str>)> { let mut out = Vec::new(); let mut from = 0; while let Some(off) = text.get(from..).and_then(|t| t.find('@')) { @@ -197,15 +203,16 @@ pub fn mention_ranges(text: &str, people: &[&str]) -> Vec> { continue; } let rest = text.get(at + 1..).unwrap_or(""); - let best = people + let who = people .iter() + .copied() .filter(|n| !n.is_empty()) .filter(|n| rest.get(..n.len()).is_some_and(|h| h.eq_ignore_ascii_case(n))) .filter(|n| rest.get(n.len()..).and_then(|t| t.chars().next()).is_none_or(|c| !c.is_alphanumeric())) - .map(|n| n.len()) - .max(); - if let Some(len) = best { - out.push(at..at + 1 + len); + .max_by_key(|n| n.len()); + let len = who.map_or_else(|| rest.find(|c: char| !c.is_alphanumeric()).unwrap_or(rest.len()), str::len); + if len > 0 { + out.push((at..at + 1 + len, who)); from = at + 1 + len; } } diff --git a/crates/engine/src/cmd/comment.rs b/crates/engine/src/cmd/comment.rs index 5666d3d1..ea3d0756 100644 --- a/crates/engine/src/cmd/comment.rs +++ b/crates/engine/src/cmd/comment.rs @@ -7,7 +7,7 @@ use std::fmt::Write as _; -use figcraft_doc::{Comment, Document, Message, Node, NodeId, NodeKind, geom}; +use figcraft_doc::{Comment, Document, Message, Node, NodeId, NodeKind, geom, mentions}; use serde_json::{Value, json}; use super::{CommandSpec, ok}; @@ -326,25 +326,42 @@ fn local(doc: &Document, anchor: Option, x: f64, y: f64) -> (f64, f64) { } } -fn message_json(m: &Message) -> Value { +/// The names an @mention resolves to: the file's people (`Document::comment_people`) and `user`, +/// as the thread view highlights them. +fn mentionable<'a>(people: &'a [String], user: &'a str) -> Vec<&'a str> { + people.iter().map(String::as_str).chain([user]).collect() +} + +/// A message as JSON, with the names it @mentions: `{name, known, you}` each, resolved against +/// `people` (`mentionable`); `known: false` is a name nobody in the file has. +fn message_json(m: &Message, people: &[&str], user: &str) -> Value { + let mut named: Vec = Vec::new(); + for (r, who) in mentions(&m.text, people) { + let name = who.or_else(|| m.text.get(r.start + 1..r.end)).unwrap_or_default(); + let v = json!({"name": name, "known": who.is_some(), "you": who.is_some_and(|w| w.eq_ignore_ascii_case(user))}); + if !named.contains(&v) { + named.push(v); + } + } json!({ "author": m.author, "agent": m.agent, "text": m.text, "created": m.created, "edited": m.edited, "reactions": m.reactions.iter().map(|r| json!({"emoji": r.emoji, "users": r.users})).collect::>(), + "mentions": named, }) } /// JSON view of a comment as `user` sees it: its page position, frame, read state and link, the -/// first message's fields, and the replies. -pub fn comment_json(doc: &Document, c: &Comment, user: &str) -> Value { +/// first message's fields, and the replies; mentions resolve against `people` (`mentionable`). +pub fn comment_json(doc: &Document, c: &Comment, user: &str, people: &[&str]) -> Value { let (x, y) = c.position(doc); let frame = frame_of(doc, c); - let mut v = message_json(&c.first); + let mut v = message_json(&c.first, people, user); let extra = json!({ "id": c.id, "page": c.page, "pageName": doc.node(c.page).map(|n| n.name.as_str()), "x": x, "y": y, "anchor": c.anchor, "frame": frame, "frameName": frame.and_then(|f| doc.node(f)).map(|n| n.name.as_str()), "resolved": c.resolved, "unread": c.is_unread(user), "link": link(c.id), - "replies": c.replies.iter().map(message_json).collect::>(), + "replies": c.replies.iter().map(|m| message_json(m, people, user)).collect::>(), }); if let (Some(m), Value::Object(e)) = (v.as_object_mut(), extra) { m.extend(e); @@ -410,7 +427,8 @@ fn open(s: &mut Session, id: u64, how: &str, who: &str) -> crate::Result s.request_ui("comment.open", json!({"id": id})); let doc = s.doc()?; let c = doc.comment(id).ok_or_else(|| bad("comment.open", "no comment"))?; - Ok(comment_json(doc, c, who)) + let people = doc.comment_people(); + Ok(comment_json(doc, c, who, &mentionable(&people, who))) } /// A list's page (None: every page), filter, sort and search text from params. @@ -796,7 +814,9 @@ pub fn specs() -> Vec { let (page, filter, sort, text) = list_params("comment.list", s, p)?; let who = author(s, p); let q = Query { page, frame: id(p, "frame"), filter, sort, text: &text }; - let list: Vec = query(doc, &q, &who).into_iter().map(|c| comment_json(doc, c, &who)).collect(); + let people = doc.comment_people(); + let people = mentionable(&people, &who); + let list: Vec = query(doc, &q, &who).into_iter().map(|c| comment_json(doc, c, &who, &people)).collect(); Ok(json!({"comments": list})) } ), diff --git a/crates/engine/src/comment_mentions_tests.rs b/crates/engine/src/comment_mentions_tests.rs new file mode 100644 index 00000000..b25a7934 --- /dev/null +++ b/crates/engine/src/comment_mentions_tests.rs @@ -0,0 +1,44 @@ +//! `comment.list` reports the names each message @mentions, by the rule the thread view highlights +//! them with: resolved against the file's people where it can be, and whether the name is you. + +use serde_json::{Value, json}; + +use crate::Session; + +fn mentions(s: &mut Session, p: Value) -> Vec { + let l = s.execute("comment.list", &p).unwrap(); + let c = &l["comments"][0]; + [&c["mentions"], &c["replies"][0]["mentions"]].into_iter().cloned().collect() +} + +#[test] +fn a_message_lists_the_names_it_mentions_and_not_an_email_address() { + let mut s = Session::new(); + let id = s.execute("comment.add", &json!({"x": 0, "y": 0, "text": "Hi", "author": "Ada Lovelace"})).unwrap()["id"].as_u64().unwrap(); + let text = "@ada lovelace and @You: please check, or ask @Bob (mail a@b.com). Thanks @Ada Lovelace"; + s.execute("comment.reply", &json!({"id": id, "text": text, "author": "Sam"})).unwrap(); + let [first, reply] = <[Value; 2]>::try_from(mentions(&mut s, json!({}))).unwrap(); + assert_eq!(first, json!([])); + assert_eq!( + reply, + json!([ + {"name": "Ada Lovelace", "known": true, "you": false}, + {"name": "You", "known": true, "you": true}, + {"name": "Bob", "known": false, "you": false}, + ]), + "two people the file knows (once each, as the file spells them), one name it doesn't, no email" + ); + // `author` says whose list it is: then Ada is "you". + let reply = mentions(&mut s, json!({"author": "Ada Lovelace"})).remove(1); + assert_eq!(reply[0], json!({"name": "Ada Lovelace", "known": true, "you": true})); +} + +#[test] +fn the_mention_rule_matches_the_highlighting_rule() { + let people = ["Sam", "Sam Lee"]; + let found = figcraft_doc::mentions("@Sam Lee, @sam, x@Sam, @Zoe!", &people); + let names: Vec<_> = found.iter().map(|(r, who)| (r.clone(), *who)).collect(); + assert_eq!(names, vec![(0..8, Some("Sam Lee")), (10..14, Some("Sam")), (23..27, None)]); + assert_eq!(figcraft_doc::mention_ranges("@Sam Lee, @sam, x@Sam, @Zoe!", &people), vec![0..8, 10..14]); + assert!(figcraft_doc::mentions("@ and a@b.com", &people).is_empty()); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2b4e7bb6..bfe47702 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -225,6 +225,8 @@ mod collab_undo_tests; #[cfg(test)] mod comment_agent_tests; #[cfg(test)] +mod comment_mentions_tests; +#[cfg(test)] mod comments_polish_tests; #[cfg(test)] mod component_codegen_tests; diff --git a/crates/mcp/src/prompts.rs b/crates/mcp/src/prompts.rs index 060f7a30..b9be1728 100644 --- a/crates/mcp/src/prompts.rs +++ b/crates/mcp/src/prompts.rs @@ -86,7 +86,7 @@ pub const PROMPTS: &[PromptDef] = &[ ], template: r#"Fix the open comment threads in this FigCraft document. -1. Read them: run_command {"command": "comment.list", "params": {"page": "{page}", "filter": "open"}}. Each thread has an id, text, pageName, frameName, anchor (a node id; null if the comment is only pinned to a page point), x/y, author, replies and link. +1. Read them: run_command {"command": "comment.list", "params": {"page": "{page}", "filter": "open"}}. Each thread has an id, text, pageName, frameName, anchor (a node id; null if the comment is only pinned to a page point), x/y, author, mentions (the names a message @mentions: {name, known, you}), replies and link. 2. For each thread, in list order: a. Inspect the anchor with get_node {"id": , "depth": 2} (or get_node on frame if anchor is null), and look at the frame: screenshot {"ids": []}. b. Make the smallest change that answers the comment with set_properties, create_node or run_command. Put the edits for one thread in one batch so the user can undo them in one step. Do not edit layers the comment does not mention. diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index 68426822..904b33b9 100644 --- a/docs/collaboration-parity.md +++ b/docs/collaboration-parity.md @@ -1,6 +1,6 @@ # Collaboration parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); 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); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); 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); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model, @@ -18,7 +18,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors, selection outlines, avatars, follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 | | Cursor chat, audio, high-fives | yes | **partial, in live sessions**: cursor chat (`/` opens a bubble at your pointer; everyone sees it as you type, it fades a few seconds after the last change, Enter sends and Escape takes it back, 100 characters; `collab.cursorChat` for agents); sent as ephemeral presence the host checks and rate-limits, never stored. No audio, high-fives or emote reactions | 2–5 | | Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen; the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser)); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer: no accounts, teams or projects | 8–17 | -| Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences (an AI agent attached over MCP comments as its own name, marked Agent); a behaviour suite from the documentation (pinning and following layers, threads, own edits and deletes, mentions, reactions, C and Shift+C, stepping, filters, saving, copy and paste); restoring a version keeps them. In a live session threads sync and anyone in it can reply, react and resolve (no accounts: a name is who you are) | (shared once multiplayer lands) | +| Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences (an AI agent attached over MCP comments as its own name, marked Agent); a behaviour suite from the documentation (pinning and following layers, threads, own edits and deletes, mentions, reactions, C and Shift+C, stepping, filters, saving, copy and paste); restoring a version keeps them. `comment.list` (CLI, MCP) gives each message its `mentions` (`{name, known, you}`, by the highlighting rule). In a live session threads sync and anyone in it can reply, react and resolve (no accounts: a name is who you are) | (shared once multiplayer lands) | | Version history: named versions, autosave checkpoints, view before restore, restore, duplicate | yes (cloud) | **done**, saved in the `.figcraft` file: restoring keeps the replaced design as an autosave first, a titled autosave becomes a named version, merging a branch records the file before and after. Versions are per person in a live session (a shared history needs accounts); no comparing two versions (Dev Mode compares a ready-for-dev layer with its state when marked) | — | | Branching and merging | yes (paid plans) | **done, also in live sessions**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived; entries for its creation and updates from main); fills, strokes, effects, layout grids, export settings, guides, interactions, annotations and Dev Mode resources merge item by item. In a live session a branch stays with its author (its ids in their own client space, far above the main file's) and merging it is one edit everyone gets; peers' edits merge against the branch's own base and conflicts resolve as offline; a view link refuses merging. Left: others in the session can't see or open your branches (the server would have to keep them); text runs conflict as a whole; a branch made offline that adds layers merges only outside a session | 1–2 | | Team / organization libraries | yes | **partial**: a file's library is published to `figcraft-server` under the file and enabled in other files by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names ([components-parity](components-parity.md), [server.md](server.md#team-libraries)). No team-wide library list, accounts or analytics over time | 4–8 | @@ -28,6 +28,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M9, #1415: `comment.list` (CLI, MCP `run_command`, the `figcraft://comments` resource) gives every message, the first and each reply, its `mentions`: `{name, known, you}` for each name it @mentions, by the rule the thread view highlights them with (`figcraft_doc::mentions`, which the highlighting now shares). A name resolves against the file's people and you, as the file spells it. An unknown name is reported with `known: false`. `a@b.com` mentions nobody. Filtering threads by a mentioned name and listing mentioned names in `comment.people` stay open. Numbers unchanged | | 2026-10-29 | minor | Bounded replay log (#1069, from a security audit): the batches a session keeps for apps that reconnect were bounded only by count (10,000 batches, 1,000,000 changes) on a desktop host and a private `figcraft-server`, so a long busy session could hold any number of bytes. `Limits::max_log_bytes` now defaults to 64 MiB, and `figcraft-server` takes it from a new `max_log_mb` setting (default 64; open to the public, also at most a quarter of `max_file_mb`, as before). An app further behind than the trimmed log catches up with the whole file, as it already did past the count limits. Nine 8 MiB renames with default limits kept all 75.5 MB before; now 58.7 MB, and a client that missed them gets a snapshot. The protocol is unchanged. | | 2026-10-29 | minor | Sibling placement in live sessions (#1065, from a security audit): every replica (the server's and each app's) placed a created, moved or reordered layer by counting each sibling that sorts before it, so a batch of many layers under one parent took quadratic time, on the server's single hub thread too. It now binary-searches the siblings' `(key, id)` order on each side of the layer's old place. The order is unchanged, ties and fractional positions included: a test replays 5,000 creations and 2,500 moves against the counting rule. 20,000 creations and 10,000 moves under two parents took 20 s in a debug build; they now take 0.3 s. | | 2026-10-29 | minor | Comments from an AI agent (M8, #1414): an agent attached over MCP commented, reacted and read threads as the user (the name from Preferences) unless it passed `author` on every call. Every command an MCP client runs now acts as that client's name from `initialize` (`clientInfo.title`, else `.name`) with " (agent)" after it, or "AI agent" without one, carried to the app as `engine.execute {agent}`; its messages, reactions and read state are its own, and its messages are marked (`agent: true` in the file and in `comment.list`, an Agent label by the name in the thread). The user's name and the comments made in the app are unchanged, and in a live session the name and marker travel with the thread to everyone. Tests in `comment_agent_tests`, the MCP `agent_comments` and attached suites, the UI comments suite and the collab diff round trip. Numbers unchanged | diff --git a/docs/mcp.md b/docs/mcp.md index a2afc7e9..e0d66b4c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1177,7 +1177,12 @@ Read state is kept per name: a new message is unread for everyone but its author `comment.markRead {id? | link?, read?}` marks one thread (or, without `id`, all of them) read or unread (not an undo step). `comment.list {page? | "all", filter? (all|open|resolved|mine|unread), sort? (date|page), query?, frame?}` returns the threads with their position, `frame`/`frameName`, -`pageName`, `unread`, `link` and replies (with `edited` times and `reactions`). Links are +`pageName`, `unread`, `link` and replies (with `edited` times and `reactions`). Each message, the +first and every reply, has `mentions`: the names it @mentions by the rule the app highlights them +with, `{name, known, you}` each. `known` means the file's people (`comment.people`, or you) have +the name, spelt as they spell it; `you` means it names you (or `author`). `@Name` counts at the +start of the text or after a character that isn't a letter or digit, so `a@b.com` mentions nobody; +an unknown name is the letters and digits after the `@`. Links are `figcraft://comment/`: `comment.link {id}` returns one, `comment.open {id | link, view?}` goes to its page, zooms to its frame (`fit`, the default; `center` keeps the zoom; `keep` doesn't move) and opens it in the app, and `comment.next {id?, dir?, …list params}` steps to the next or previous diff --git a/docs/roadmap.md b/docs/roadmap.md index 2d4d0633..86bc1c40 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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-10-28 · **Change:** minor (comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M9, #1415: `comment.list` (CLI, MCP `run_command`, the `figcraft://comments` resource) gives every message, the first and each reply, its `mentions`: `{name, known, you}` for each name it @mentions, by the rule the thread view highlights them with (`figcraft_doc::mentions`, which the highlighting now shares). A name resolves against the file's people and you, as the file spells it. An unknown name is reported with `known: false`. `a@b.com` mentions nobody. Filtering threads by a mentioned name and listing mentioned names in `comment.people` stay open. Numbers unchanged | | 2026-10-29 | minor | M8, #1061: a mask group's pixmaps cover only where its mask or content can paint within the view (a 40 × 40 mask on a 3000 × 2000 view: 96 MB → 61.5 kB, same pixels), and isolated layers share a 2 GiB budget held at once per render thread; a layer past it is left out with a warning. Tests: `mask_bounds_tests` | | 2026-10-29 | minor | M2, #1084: a component can't contain its own instance, directly or through other components (each variant counts as its own component, so a variant may hold a sibling variant's instance unless that one leads back to it). Edits that would close such a loop are refused with it named, a file holding one opens with an instance detached and a note, and a rebuild never expands an instance inside a copy of itself (a loop merged in a live session stops growing). Tests: `component_cycle_tests` | | 2026-10-29 | minor | Which copy is which (M8, #995): `edit.duplicate` with `map: true` also returns `map`, each original layer's id, descendants included (nested frames, groups, instances and their sublayers), paired with its copy's id, so a batch can edit inside a copy through a step reference (`"field": "map/6"`) instead of inspecting it. Without `map` the result is unchanged. Regression tests in `crates/engine/src/duplicate_map_tests.rs`. Numbers unchanged | From f702105ed0280a4552cd97e656097aecaa02e970 Mon Sep 17 00:00:00 2001 From: BFlat Date: Thu, 29 Oct 2026 22:25:40 +0700 Subject: [PATCH 42/63] M4: a letter and its combining marks shape with one font (#1478) (#1532) * M4: a letter and its combining marks shape with one font (#1478) * M4: the stacked-mark test checks positions only in fonts whose anchors stack them (#1478) * M4: the cluster face rule is tested with stand-in faces, so it runs without system fonts (#1478) * M4: the #1478 tests use stand-in faces only, so they run everywhere --- ROADMAP.md | 3 +- crates/text/src/layout.rs | 63 +++++++++++++++++++++-------- crates/text/src/lib.rs | 2 + crates/text/src/vietnamese_tests.rs | 49 ++++++++++++++++++++++ docs/roadmap.md | 3 +- docs/target-app-parity.md | 3 +- 6 files changed, 103 insertions(+), 20 deletions(-) create mode 100644 crates/text/src/vietnamese_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index df1d625c..c4d98d90 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-29 · **Change:** minor (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-10-29 · **Change:** minor (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 @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | patch | M4, #1478: a letter and its combining marks are shaped with one font that has them all. Text typed decomposed (NFD, `E` + U+0302 + U+0309 for Ể) in a font with the letters but not the marks drew missing-glyph boxes beside and above the letter, while the precomposed form fell back to a font that has it; each grapheme cluster now takes the first face (the style's own, then the fallbacks) that covers every char in it (joiners and variation selectors need no glyph), else the letter's own face as before. Fonts that cover Vietnamese were already right: mark positioning (GPOS `mark`/`mkmk`) stacks the marks over the letter on the canvas, in PNG, SVG and PDF. Tests in `crates/text/src/vietnamese_tests.rs`. Numbers unchanged | | 2026-10-29 | minor | M9, #1415: `comment.list` (CLI, MCP `run_command`, the `figcraft://comments` resource) gives every message, the first and each reply, its `mentions`: `{name, known, you}` for each name it @mentions, by the rule the thread view highlights them with (`figcraft_doc::mentions`, which the highlighting now shares). A name resolves against the file's people and you, as the file spells it. An unknown name is reported with `known: false`. `a@b.com` mentions nobody. Filtering threads by a mentioned name and listing mentioned names in `comment.people` stay open. Numbers unchanged | | 2026-10-29 | minor | M8, #1061: a mask group's pixmaps (the mask, its own layer, the content and the pixels it goes onto) cover only where the mask or its content can paint (effects included) within the view, rounded out to whole pixels, instead of the whole view: a 40 × 40 mask on a 3000 × 2000 view held 96 MB, now 61.5 kB, drawing the same pixels. Isolated layers (effects, opacity, masks, clipping frames) also share one budget of 2 GiB held at once per render thread (`figcraft_render::layer_budget`); a layer past it is left out with a warning, so 64 nested full-view layers no longer ask for tens of GB. Tests: `mask_bounds_tests`. Numbers unchanged | | 2026-10-29 | minor | M2: components can't contain their own instances (#1084). Any edit that would make a component hold an instance of itself, directly or through other components (an instance-swap property's current value included), is refused with the loop named: creating, placing, swapping, switching variants, moving, pasting and instance-swap values. Each variant counts as a component of its own, so a variant holding a sibling variant's instance is fine unless that one leads back to it (`components::check_component_cycles`, beside the instance structure check); a dropped instance lands on the page instead. A file holding such a loop (opened or imported) has one instance per loop detached, with a note; a loop merged in a live session is reported, and a rebuild never expands an instance inside a copy of itself, so its copies stop growing (12 rebuilds of a two-component loop: 25 → 113 layers before, 25 throughout now). Tests: `component_cycle_tests`. Numbers unchanged | diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index cf0d5512..3ff78cfa 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -383,33 +383,24 @@ fn shape_text(text: &TextData) -> ShapedText { let Some(Some(primary)) = faces.get(st_idx) else { continue }; let size = sanitize_size(style.font_size); let spacing = style.letter_spacing.resolve(size); + let span_fallbacks = style_fallbacks.get(st_idx).unwrap_or(&fallbacks); + // The face for the grapheme cluster starting at `i` (clipped to the span). + let face_at = |i: usize| { + let to = (i + 1..*end).find(|&j| cluster_start.get(j).copied().unwrap_or(true)).unwrap_or(*end); + cluster_face(chars.get(i..to).unwrap_or(&[]), primary, &emoji, span_fallbacks) + }; // Segment by face. let mut seg_start = *start; while seg_start < *end { - let face_for = |c: char| -> Arc { - // Emoji take a colour face before a face without colour glyphs (the style's own or a - // symbol font), which would draw them in one colour. - if is_emoji(c) - && !primary.has_color() - && let Some(f) = emoji.iter().find(|f| f.has_char(c)) - { - return f.clone(); - } - if is_line_break(c) || primary.has_char(c) { - return primary.clone(); - } - let fallbacks = style_fallbacks.get(st_idx).unwrap_or(&fallbacks); - fallbacks.iter().find(|f| f.has_char(c)).cloned().unwrap_or_else(|| primary.clone()) - }; let first = chars.get(seg_start).copied().unwrap_or(' '); - let face = face_for(first); + let face = face_at(seg_start); let level = level_at(seg_start); let mut seg_end = seg_start + 1; while seg_end < *end && chars.get(seg_end).is_some_and(|c| { !is_line_break(*c) && !is_line_break(first) - && (!cluster_start.get(seg_end).copied().unwrap_or(true) || face_for(*c).id == face.id) + && (!cluster_start.get(seg_end).copied().unwrap_or(true) || face_at(seg_end).id == face.id) }) && level_at(seg_end) == level { @@ -442,6 +433,44 @@ fn shape_text(text: &TextData) -> ShapedText { ShapedText { chars, styles, faces, fallbacks, items, bidi, cluster_start } } +/// The face a grapheme cluster `cl` is shaped with. A letter and its marks come from one face that +/// has them all: a mark the letter's face lacks would draw as a missing glyph beside it, and a mark +/// from another face would sit apart from it (#1478). +fn cluster_face(cl: &[char], primary: &Arc, emoji: &[Arc], fallbacks: &[Arc]) -> Arc { + pick_cluster_face(cl, primary, emoji, fallbacks, |f, c| f.has_char(c), |f| f.has_color()).clone() +} + +/// [`cluster_face`]'s rule over any kind of face (`has`: whether a face has a char's glyph, +/// `color`: whether it has colour glyphs), so it can be checked without fonts. +pub(crate) fn pick_cluster_face<'a, F>( + cl: &[char], + primary: &'a F, + emoji: &'a [F], + fallbacks: &'a [F], + has: impl Fn(&F, char) -> bool, + color: impl Fn(&F) -> bool, +) -> &'a F { + let c = cl.first().copied().unwrap_or(' '); + // Emoji take a colour face before a face without colour glyphs (the style's own or a symbol + // font), which would draw them in one colour. + if is_emoji(c) + && !color(primary) + && let Some(f) = emoji.iter().find(|f| has(f, c)) + { + return f; + } + // Joiners and variation selectors need no glyph: shaping hides them. + let covers = |f: &F| { + cl.iter().all(|&m| has(f, m) || matches!(m, '\u{034F}' | '\u{200C}' | '\u{200D}' | '\u{FE00}'..='\u{FE0F}' | '\u{E0100}'..='\u{E01EF}')) + }; + if is_line_break(c) || covers(primary) { + return primary; + } + // No face has the whole cluster: the letter's own face, as for a lone letter. + let own = || if has(primary, c) { Some(primary) } else { fallbacks.iter().find(|f| has(f, c)) }; + fallbacks.iter().find(|f| covers(f)).or_else(own).unwrap_or(primary) +} + fn line_break_after(items: &[Item], end: usize) -> bool { end.checked_sub(1).and_then(|i| items.get(i)).is_some_and(|item| is_line_break(item.ch)) } diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 2e99b64e..9f5e0cd1 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -166,3 +166,5 @@ mod saved_breaks_tests; mod tail_tests; #[cfg(test)] mod tests; +#[cfg(test)] +mod vietnamese_tests; diff --git a/crates/text/src/vietnamese_tests.rs b/crates/text/src/vietnamese_tests.rs new file mode 100644 index 00000000..661573dc --- /dev/null +++ b/crates/text/src/vietnamese_tests.rs @@ -0,0 +1,49 @@ +//! Vietnamese stacked diacritics (#1478): a letter and its marks are shaped with one face that has +//! them all, whether written precomposed (NFC) or decomposed (NFD). The face rule is checked with +//! stand-in faces, so it runs on any machine whatever fonts it has. + +use crate::layout::pick_cluster_face; + +/// A stand-in face: the chars it has glyphs for, and whether those are colour glyphs. +struct Fake { + chars: &'static str, + color: bool, +} + +const fn fake(chars: &'static str) -> Fake { + Fake { chars, color: false } +} + +fn pick<'a>(cluster: &str, primary: &'a Fake, emoji: &'a [Fake], fallbacks: &'a [Fake]) -> &'a str { + let cl: Vec = cluster.chars().collect(); + pick_cluster_face(&cl, primary, emoji, fallbacks, |f, c| f.chars.contains(c), |f| f.color).chars +} + +#[test] +fn a_cluster_takes_one_face_that_has_all_its_chars() { + let latin = fake("EU"); + let full = [fake("EU\u{0302}\u{0309}\u{031B}\u{0301}")]; + let marks_only = [fake("\u{0302}\u{0309}"), fake("EU\u{0302}\u{0309}")]; + // The style's own face has the whole cluster: it keeps it. + assert_eq!(pick("E\u{0302}\u{0309}", &full[0], &[], &[]), full[0].chars); + // The style's face lacks a mark (Ể typed decomposed in a Latin-1 font): the first fallback with + // the letter and every mark, not one with the marks alone. + assert_eq!(pick("E\u{0302}\u{0309}", &latin, &[], &marks_only), "EU\u{0302}\u{0309}"); + // Joiners and variation selectors need no glyph of their own. + assert_eq!(pick("E\u{200D}\u{FE0F}", &latin, &[], &full), "EU"); + // No face has the whole cluster: the letter's own face, then the first fallback with the letter. + assert_eq!(pick("E\u{031B}\u{0303}", &latin, &[], &marks_only), "EU"); + assert_eq!(pick("Q\u{0302}", &fake("U"), &[], &[fake("\u{0302}"), fake("Q")]), "Q"); + assert_eq!(pick("Z\u{0302}", &latin, &[], &marks_only), "EU"); + // A line break stays with the style's face. + assert_eq!(pick("\n", &latin, &[], &full), "EU"); +} + +#[test] +fn an_emoji_takes_a_colour_face_unless_the_style_has_colour_glyphs() { + let emoji = [Fake { chars: "\u{1F600}\u{1F601}", color: true }]; + let mono = fake("\u{1F600}"); + assert_eq!(pick("\u{1F600}", &mono, &emoji, &[]), emoji[0].chars); + let colour = Fake { chars: "\u{1F600}", color: true }; + assert!(std::ptr::eq(pick_cluster_face(&['\u{1F600}'], &colour, &emoji, &[], |f, c| f.chars.contains(c), |f| f.color), &colour)); +} diff --git a/docs/roadmap.md b/docs/roadmap.md index 86bc1c40..64736092 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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-10-28 · **Change:** minor (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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | patch | M4, #1478: a letter and its combining marks are shaped with one font that has them all. Text typed decomposed (NFD, `E` + U+0302 + U+0309 for Ể) in a font with the letters but not the marks drew missing-glyph boxes beside and above the letter, while the precomposed form fell back to a font that has it; each grapheme cluster now takes the first face (the style's own, then the fallbacks) that covers every char in it (joiners and variation selectors need no glyph), else the letter's own face as before. Fonts that cover Vietnamese were already right: mark positioning (GPOS `mark`/`mkmk`) stacks the marks over the letter on the canvas, in PNG, SVG and PDF. Tests in `crates/text/src/vietnamese_tests.rs`. Numbers unchanged | | 2026-10-29 | minor | M9, #1415: `comment.list` (CLI, MCP `run_command`, the `figcraft://comments` resource) gives every message, the first and each reply, its `mentions`: `{name, known, you}` for each name it @mentions, by the rule the thread view highlights them with (`figcraft_doc::mentions`, which the highlighting now shares). A name resolves against the file's people and you, as the file spells it. An unknown name is reported with `known: false`. `a@b.com` mentions nobody. Filtering threads by a mentioned name and listing mentioned names in `comment.people` stay open. Numbers unchanged | | 2026-10-29 | minor | M8, #1061: a mask group's pixmaps cover only where its mask or content can paint within the view (a 40 × 40 mask on a 3000 × 2000 view: 96 MB → 61.5 kB, same pixels), and isolated layers share a 2 GiB budget held at once per render thread; a layer past it is left out with a warning. Tests: `mask_bounds_tests` | | 2026-10-29 | minor | M2, #1084: a component can't contain its own instance, directly or through other components (each variant counts as its own component, so a variant may hold a sibling variant's instance unless that one leads back to it). Edits that would close such a loop are refused with it named, a file holding one opens with an instance detached and a note, and a rebuild never expands an instance inside a copy of itself (a loop merged in a live session stops growing). Tests: `component_cycle_tests` | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f5b3b550..49429d31 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -296,7 +296,8 @@ Hours in this narrative are the builders' estimates; the tables above supersede decorations from the font's metrics; CJK line breaking (between ideographs, kana and Hangul, no break inside Latin words, kinsoku to JIS X 4051 for closing and opening punctuation, small kana (halfwidth too), the prolonged sound mark, the iteration marks, the (semi-)voiced sound marks - and the halfwidth parentheses); a style's metrics from its own font, not a fallback; carets, Delete and + and the halfwidth parentheses); a style's metrics from its own font, not a fallback; a letter and + its combining marks (Vietnamese typed decomposed, say) from one font that has them all; carets, Delete and Backspace by grapheme cluster; double-click and word moves by script run; IME composition with CJK; vertical trim (cap height to baseline). The tail suite (`crates/text/src/tail_tests.rs`, `crates/engine/src/text_tail_tests.rs`): lists per paragraph, nested up to 8 levels with Tab and From 411bce2f81e082759021d96a8bd8d897c20fc42b Mon Sep 17 00:00:00 2001 From: Filip Strajnar Date: Thu, 29 Oct 2026 16:43:42 +0100 Subject: [PATCH 43/63] M4: Thai, Lao, Khmer and Burmese lines break between words (#1219) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These scripts write no spaces between words, so a paragraph was one word that only broke where it overflowed, mid-word (พัฒนาซอ|ฟต์แวร์). breaks::word_breaks finds the word starts inside each run of them with ICU4X's line segmenter (icu_segmenter 2.3, matching the icu_* crates in the lock), and break_lines may break there when the next glyph starts a new cluster. Where a run meets other text the UAX #14 subset decides as before; a word wider than its box keeps #1400's emergency break. Desktop uses the segmenter's dictionaries; the browser uses its LSTM models (about 0.39 MB of wasm instead of 1.86 MB, which the 24 MiB web gate can't take). ICU4X breaks between stray marks are filtered through safe_emergency_break, so junk input never splits a cluster. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LvG6DyK6i9Nvvsp3tp77UP --- Cargo.lock | 24 ++++++ Cargo.toml | 5 ++ ROADMAP.md | 3 +- crates/text/Cargo.toml | 7 ++ crates/text/src/behaviour_tests.rs | 35 +++++++++ crates/text/src/breaks.rs | 119 ++++++++++++++++++++++++++++- crates/text/src/layout.rs | 20 +++-- docs/roadmap.md | 3 +- docs/target-app-parity.md | 8 +- 9 files changed, 213 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 335b0d62..0207d424 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1965,6 +1965,7 @@ dependencies = [ "figcraft-doc", "figcraft-geom", "harfrust 0.13.3", + "icu_segmenter", "log", "objc2 0.6.5", "png", @@ -2743,6 +2744,29 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_segmenter" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db" +dependencies = [ + "core_maths", + "icu_collections", + "icu_locale_fallback", + "icu_provider", + "icu_segmenter_data", + "potential_utf", + "smallvec", + "utf8_iter", + "zerovec", +] + +[[package]] +name = "icu_segmenter_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" + [[package]] name = "idna" version = "1.1.0" diff --git a/Cargo.toml b/Cargo.toml index 9177f208..a97efa95 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,6 +78,11 @@ skrifa = "0.47" harfrust = "0.13" unicode-bidi = "0.3" unicode-segmentation = "1.13" +# Line breaks between the words of Thai, Lao, Khmer and Burmese, which are written without spaces +# (#1219): ICU4X's line segmenter with its compiled data (pure Rust, Unicode-3.0 like the icu_* +# crates already in the lock, whose version it matches). figcraft-text picks the dictionaries, or +# in the browser the smaller LSTM models. +icu_segmenter = { version = "2.3", default-features = false, features = ["compiled_data"] } # Code export: layer names transliterated into ASCII identifiers (pure Rust data tables, zero # dependencies, BSD-3-Clause, no_std so the web build keeps it too). deunicode = "1.6" diff --git a/ROADMAP.md b/ROADMAP.md index c4d98d90..0974ed82 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-29 · **Change:** minor (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-10-29 · **Change:** minor (Thai, Lao, Khmer and Burmese lines break between words (#1219); 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 @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M4, #1219: Thai, Lao, Khmer and Burmese text, written without spaces between words, wraps between words instead of where a line overflows (`พัฒนาซอ|ฟต์แวร์` → `พัฒนา|ซอฟต์แวร์`). `figcraft_text::breaks::word_breaks` finds the word starts inside each run of these scripts with ICU4X's line segmenter (`icu_segmenter` 2.3): its dictionaries on desktop, its LSTM models in the browser (about 0.39 MB of wasm instead of 1.86 MB, which the 24 MiB gate can't take; they split a few compounds the dictionaries keep whole). Where a run meets spaces, Latin or CJK the UAX #14 rules decide as before, and a word wider than its box still takes #1400's emergency break. Text without these scripts does no extra work. Grade unchanged. Evidence: `breaks::tests::thai_lines_break_between_words`, `lao_khmer_and_burmese_lines_break_between_words`, `word_breaks_survive_junk`, `behaviour_tests::thai_lines_break_between_words` (fails on main). | | 2026-10-29 | patch | M4, #1478: a letter and its combining marks are shaped with one font that has them all. Text typed decomposed (NFD, `E` + U+0302 + U+0309 for Ể) in a font with the letters but not the marks drew missing-glyph boxes beside and above the letter, while the precomposed form fell back to a font that has it; each grapheme cluster now takes the first face (the style's own, then the fallbacks) that covers every char in it (joiners and variation selectors need no glyph), else the letter's own face as before. Fonts that cover Vietnamese were already right: mark positioning (GPOS `mark`/`mkmk`) stacks the marks over the letter on the canvas, in PNG, SVG and PDF. Tests in `crates/text/src/vietnamese_tests.rs`. Numbers unchanged | | 2026-10-29 | minor | M9, #1415: `comment.list` (CLI, MCP `run_command`, the `figcraft://comments` resource) gives every message, the first and each reply, its `mentions`: `{name, known, you}` for each name it @mentions, by the rule the thread view highlights them with (`figcraft_doc::mentions`, which the highlighting now shares). A name resolves against the file's people and you, as the file spells it. An unknown name is reported with `known: false`. `a@b.com` mentions nobody. Filtering threads by a mentioned name and listing mentioned names in `comment.people` stay open. Numbers unchanged | | 2026-10-29 | minor | M8, #1061: a mask group's pixmaps (the mask, its own layer, the content and the pixels it goes onto) cover only where the mask or its content can paint (effects included) within the view, rounded out to whole pixels, instead of the whole view: a 40 × 40 mask on a 3000 × 2000 view held 96 MB, now 61.5 kB, drawing the same pixels. Isolated layers (effects, opacity, masks, clipping frames) also share one budget of 2 GiB held at once per render thread (`figcraft_render::layer_budget`); a layer past it is left out with a warning, so 64 nested full-view layers no longer ask for tens of GB. Tests: `mask_bounds_tests`. Numbers unchanged | diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml index 9392505d..aea3d3be 100644 --- a/crates/text/Cargo.toml +++ b/crates/text/Cargo.toml @@ -17,6 +17,7 @@ harfrust = { workspace = true } png = { workspace = true } unicode-bidi = { workspace = true } unicode-segmentation = { workspace = true } +icu_segmenter = { workspace = true } log = { workspace = true } # Fonts that font managers (RightFont, FontBase, Typeface) activate without copying them into a font @@ -29,6 +30,12 @@ log = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] craft-font-catalog = { workspace = true } +# The browser build finds Thai, Lao, Khmer and Burmese words with ICU4X's LSTM models (~0.3 MB of +# wasm) instead of its dictionaries (~1.8 MB, which would take the .wasm past the 24 MiB gate in +# packaging/web/package.sh). +[target.'cfg(target_arch = "wasm32")'.dependencies] +icu_segmenter = { workspace = true, features = ["lstm"] } + [target.'cfg(target_os = "macos")'.dependencies] core-text = { version = "=22.0.0", default-features = false, features = ["link"] } objc2 = "0.6" diff --git a/crates/text/src/behaviour_tests.rs b/crates/text/src/behaviour_tests.rs index c2a935c8..fe63e386 100644 --- a/crates/text/src/behaviour_tests.rs +++ b/crates/text/src/behaviour_tests.rs @@ -384,6 +384,41 @@ fn thai_emergency_breaks_keep_marks_and_leading_vowels_with_their_letter() { assert!(breaks > 0, "the sentence wraps at least once across these widths"); } +/// Wrapped Thai lines start words at every width that fits the widest word (#1219). +#[test] +fn thai_lines_break_between_words() { + // #1219: Thai writes no spaces, so lines break where a word starts (ICU4X's word data), never + // inside a word that fits on a line: at every width from the widest word's to the sentence's, + // each wrapped line starts a word. Font-free: without a Thai face, missing glyphs measure too. + let words = ["ระบบ", "ช่วย", "ให้", "ทีม", "พัฒนา", "ซอฟต์แวร์", "จัด", "ทำ", "เอกสาร", "ที่", "มี", "โครงสร้าง", "ได้", "ใน", "ที่", "เดียว"]; + if face().is_none() { + return; + } + let starts: Vec = words + .iter() + .scan(0, |at, w| { + let start = *at; + *at += w.chars().count(); + Some(start) + }) + .collect(); + let s = words.concat(); + let width = |s: &str| layout(&text(s, TextAutoResize::WidthAndHeight), None, None).caret(s.chars().count()).0; + let widest = words.iter().map(|w| width(w)).fold(0.0, f64::max) + 1.0; + let total = width(&s); + let t = text(&s, TextAutoResize::Height); + let mut wraps = 0; + for k in 0..20 { + let box_width = widest + (total - widest) * f64::from(k) / 20.0; + let l = layout(&t, Some(box_width), None); + for line in l.lines.iter().skip(1) { + assert!(starts.contains(&line.char_start), "at {box_width:.1} px a line starts inside a word: {:?}", line_texts(&t, &l)); + wraps += 1; + } + } + assert!(wraps >= 20, "the sentence wraps at every width below its own"); +} + #[test] fn symbols_come_from_a_symbol_font_when_one_is_installed() { // ⌖ (U+2316) and ⤒ (U+2912) are in no Latin text face. With a symbol font that has them (Apple diff --git a/crates/text/src/breaks.rs b/crates/text/src/breaks.rs index fbccb970..6d094e9d 100644 --- a/crates/text/src/breaks.rs +++ b/crates/text/src/breaks.rs @@ -1,6 +1,9 @@ //! Where lines may break and where carets may stop: break opportunities (a small subset of UAX #14 -//! with the CJK line-start and line-end rules) and grapheme clusters (UAX #29). +//! with the CJK line-start and line-end rules, plus the word boundaries of Thai, Lao, Khmer and +//! Burmese from ICU4X's line segmenter) and grapheme clusters (UAX #29). +use icu_segmenter::options::LineBreakOptions; +use icu_segmenter::{LineSegmenter, LineSegmenterBorrowed}; use unicode_segmentation::UnicodeSegmentation; /// The break class of a char. @@ -76,6 +79,58 @@ pub(crate) fn is_space(c: char) -> bool { class(c) == Class::Space } +/// Whether `c` is in a script written without spaces between words that the line segmenter has +/// word data for (UAX #14 class SA): Thai, Lao, Burmese (with the Myanmar extensions) and Khmer. +fn writes_no_spaces(c: char) -> bool { + matches!(c, '\u{0E00}'..='\u{0EFF}' | '\u{1000}'..='\u{109F}' | '\u{1780}'..='\u{17FF}' | '\u{19E0}'..='\u{19FF}' | '\u{A9E0}'..='\u{A9FF}' | '\u{AA60}'..='\u{AA7F}') +} + +/// ICU4X's line segmenter with its dictionaries for Thai, Lao, Khmer and Burmese words. +#[cfg(not(target_arch = "wasm32"))] +fn segmenter() -> LineSegmenterBorrowed<'static> { + LineSegmenter::new_dictionary(LineBreakOptions::default()) +} + +/// ICU4X's line segmenter with its LSTM models for Thai, Lao, Khmer and Burmese words: a sixth of +/// the dictionaries' size, which the browser's download budget can't take (crates/text/Cargo.toml), +/// though they split a few compounds the dictionaries keep whole. +#[cfg(target_arch = "wasm32")] +fn segmenter() -> LineSegmenterBorrowed<'static> { + LineSegmenter::new_lstm(LineBreakOptions::default()) +} + +/// For each char of `chars`, whether a line may break before it because a Thai, Lao, Khmer or +/// Burmese word starts there (#1219). These scripts write no spaces between words, so the words come +/// from the segmenter's word data. Only breaks inside a run of these scripts are marked: where the +/// run meets other text, [`break_between`] decides. Empty when the text has none of them. +pub(crate) fn word_breaks(chars: &[char]) -> Vec { + if !chars.iter().copied().any(writes_no_spaces) { + return Vec::new(); + } + let segmenter = segmenter(); + let mut breaks = vec![false; chars.len()]; + let mut at = 0; + for run in chars.chunk_by(|a, b| writes_no_spaces(*a) == writes_no_spaces(*b)) { + if run.first().copied().is_some_and(writes_no_spaces) { + let text: String = run.iter().collect(); + let offsets: Vec = text.char_indices().map(|(byte, _)| byte).collect(); + for byte in segmenter.segment_str(&text) { + // The run's start (char 0) and its end (no char) are not inside it. Marks with no + // letter before them can make ICU4X break before a mark: never split those. + if let Ok(k) = offsets.binary_search(&byte) + && let (Some(&a), Some(&b)) = (k.checked_sub(1).and_then(|j| run.get(j)), run.get(k)) + && safe_emergency_break(a, b) + && let Some(brk) = breaks.get_mut(at + k) + { + *brk = true; + } + } + } + at += run.len(); + } + breaks +} + /// Whether a line may break between `a` and the `b` after it when the text offers no break /// opportunity and has to break inside a word (Thai, Lao, Khmer and Burmese write no spaces): /// never before a letter's trailing mark, which belongs to the letter before it, and never after a @@ -156,4 +211,66 @@ mod tests { assert!(safe_emergency_break('\u{0E01}', '\u{0E02}'), "between two letters is fine"); assert!(safe_emergency_break('\u{0E2B}', '\u{0E40}'), "before a leading vowel is fine"); } + + /// The text split where [`word_breaks`] marks a word start. + fn split_words(text: &str) -> Vec { + let chars: Vec = text.chars().collect(); + let breaks = word_breaks(&chars); + let mut words = vec![String::new()]; + for (k, c) in chars.iter().enumerate() { + if breaks.get(k).copied().unwrap_or(false) { + words.push(String::new()); + } + words.last_mut().unwrap().push(*c); + } + words + } + + /// Thai words come from the dictionary, compounds and leading vowels included. + #[test] + fn thai_lines_break_between_words() { + // #1219's sentence ("the system helps software teams produce structured documents in one + // place"): ซอฟต์แวร์ (software) and โครงสร้าง (structure) stay whole, and no word is split + // after a leading vowel or before a mark. Pinned to ICU4X 2.3's Thai dictionary. + let words = ["ระบบ", "ช่วย", "ให้", "ทีม", "พัฒนา", "ซอฟต์แวร์", "จัด", "ทำ", "เอกสาร", "ที่", "มี", "โครงสร้าง", "ได้", "ใน", "ที่", "เดียว"]; + assert_eq!(split_words(&words.concat()), words); + } + + /// Lao, Khmer and Burmese words come from their dictionaries too. + #[test] + fn lao_khmer_and_burmese_lines_break_between_words() { + // "new world, the Lao language"; "Khmer is the official language"; "the Burmese language". + for words in [&["ໂລກ", "ໃໝ່", "ພາສາ", "ລາວ"][..], &["ភាសាខ្មែរ", "គឺជា", "ភាសាផ្លូវការ"], &["မြန်မာဘာသာ", "စကား", "သည်"]] + { + assert_eq!(split_words(&words.concat()), words); + } + } + + /// Text without these scripts is left to the UAX #14 rules, and so is the edge of a run. + #[test] + fn word_breaks_leave_other_text_alone() { + assert!(word_breaks(&"Latin, 日本語 and Ελληνικά".chars().collect::>()).is_empty(), "no work without these scripts"); + // Where a Thai run meets Latin or a space, break_between decides: no break inside "ไทยabc". + assert_eq!(split_words("ภาษาไทยabcภาษาไทย ไทย"), ["ภาษา", "ไทยabcภาษา", "ไทย ไทย"]); + } + + /// Malformed runs neither panic nor start a word on a mark. + #[test] + fn word_breaks_survive_junk() { + // Stray marks, a lone leading vowel, joiners and a long run: no panic, and never a word + // start on a mark, which belongs to the letter before it. + let long = "ก\u{0E49}".repeat(5_000); + for text in ["\u{0E49}\u{0E49}\u{0E33}", "เ", "ก\u{200D}ข\u{200C}ค", "\u{1000}\u{103A}\u{200D}\u{1004}", &long] { + let chars: Vec = text.chars().collect(); + let breaks = word_breaks(&chars); + assert_eq!(breaks.len(), chars.len(), "{text:?}"); + for (k, c) in chars.iter().enumerate() { + assert!( + !(breaks.get(k).copied().unwrap_or(false) && is_trailing_mark(*c)), + "{text:?}: a word starts on mark U+{:04X} at {k}", + *c as u32 + ); + } + } + } } diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 3ff78cfa..ceea978f 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -331,8 +331,12 @@ struct ShapedText { bidi: Option, /// For each char, whether it starts a grapheme cluster. cluster_start: Vec, + /// For each char, whether a Thai, Lao, Khmer or Burmese word starts there (empty without them). + word_breaks: Vec, } +/// Shape `text`: each span split by paragraph, bidi run and face into glyphs (items, in logical +/// order), with the grapheme cluster starts and word breaks line breaking needs. fn shape_text(text: &TextData) -> ShapedText { let chars: Vec = text.characters.chars().collect(); let spans = text.spans(); @@ -430,7 +434,8 @@ fn shape_text(text: &TextData) -> ShapedText { } } - ShapedText { chars, styles, faces, fallbacks, items, bidi, cluster_start } + let word_breaks = crate::breaks::word_breaks(&chars); + ShapedText { chars, styles, faces, fallbacks, items, bidi, cluster_start, word_breaks } } /// The face a grapheme cluster `cl` is shaped with. A letter and its marks come from one face that @@ -522,11 +527,13 @@ impl SavedStarts<'_> { } /// The lines (item range, paragraph end, paragraph), wrapped by measure or at `saved` line starts; -/// None when a saved line would overflow its box by more than [`SAVED_LINE_SLACK`]. +/// None when a saved line would overflow its box by more than [`SAVED_LINE_SLACK`]. Lines break +/// where [`crate::breaks::break_between`] allows, and between the words `word_breaks` marks. fn break_lines( items: &[Item], chars: &[char], cluster_start: &[bool], + word_breaks: &[bool], wrap: Option, line_indent_of: &impl Fn(usize, bool, usize) -> f64, rtl_para_at: &impl Fn(usize) -> bool, @@ -595,7 +602,10 @@ fn break_lines( } } x += w; - if items.get(i + 1).is_some_and(|next| crate::breaks::break_between(it.ch, next.ch)) { + // A word starts at the next glyph only if that glyph starts a new cluster (marks are glyphs + // of their letter's cluster). + let word_start = |next: &Item| it.char_end <= next.char_start && word_breaks.get(next.char_start).copied().unwrap_or(false); + if items.get(i + 1).is_some_and(|next| crate::breaks::break_between(it.ch, next.ch) || word_start(next)) { last_break = Some(i); } i += 1; @@ -816,7 +826,7 @@ fn finish_layout(out: &mut TextLayout, finish: LayoutFinish<'_>) { /// Lay out `text` for a box of `box_width` (used when the text wraps or aligns; for auto width /// pass `None`). `box_height` is used for truncation only. pub fn layout(text: &TextData, box_width: Option, box_height: Option) -> TextLayout { - let ShapedText { chars, styles, faces, fallbacks, items, bidi, cluster_start } = shape_text(text); + let ShapedText { chars, styles, faces, fallbacks, items, bidi, cluster_start, word_breaks } = shape_text(text); let rtl_para_at = |i: usize| bidi.as_ref().and_then(|b| b.rtl_para.get(i)).copied().unwrap_or(false); // List markers sit in a gutter (one more per nesting level) and wrapped lines hang at the @@ -852,7 +862,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) TextAutoResize::WidthAndHeight => None, _ => box_width.filter(|w| w.is_finite() && *w > 0.0), }; - let breaks = |saved| break_lines(&items, &chars, &cluster_start, wrap, &line_indent_of, &rtl_para_at, hang_start, hang_end, saved); + let breaks = |saved| break_lines(&items, &chars, &cluster_start, &word_breaks, wrap, &line_indent_of, &rtl_para_at, hang_start, hang_end, saved); // Where the file saved its line breaks, they win while the text and box are as saved. let saved = wrap.and_then(|w| text.saved_breaks_for(w)).map(|starts| SavedStarts { starts, next: 0 }); let lines = saved.and_then(|s| breaks(Some(s))).or_else(|| breaks(None)).unwrap_or_default(); diff --git a/docs/roadmap.md b/docs/roadmap.md index 64736092..d6dc0f9e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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-10-29 · **Change:** minor (Thai, Lao, Khmer and Burmese lines break between words (#1219); 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M4, #1219: Thai, Lao, Khmer and Burmese text, written without spaces between words, wraps between words instead of where a line overflows (`พัฒนาซอ|ฟต์แวร์` → `พัฒนา|ซอฟต์แวร์`). `figcraft_text::breaks::word_breaks` finds the word starts inside each run of these scripts with ICU4X's line segmenter (`icu_segmenter` 2.3): its dictionaries on desktop, its LSTM models in the browser (about 0.39 MB of wasm instead of 1.86 MB, which the 24 MiB gate can't take; they split a few compounds the dictionaries keep whole). Where a run meets spaces, Latin or CJK the UAX #14 rules decide as before, and a word wider than its box still takes #1400's emergency break. Text without these scripts does no extra work. Grade unchanged. Evidence: `breaks::tests::thai_lines_break_between_words`, `lao_khmer_and_burmese_lines_break_between_words`, `word_breaks_survive_junk`, `behaviour_tests::thai_lines_break_between_words` (fails on main). | | 2026-10-29 | patch | M4, #1478: a letter and its combining marks are shaped with one font that has them all. Text typed decomposed (NFD, `E` + U+0302 + U+0309 for Ể) in a font with the letters but not the marks drew missing-glyph boxes beside and above the letter, while the precomposed form fell back to a font that has it; each grapheme cluster now takes the first face (the style's own, then the fallbacks) that covers every char in it (joiners and variation selectors need no glyph), else the letter's own face as before. Fonts that cover Vietnamese were already right: mark positioning (GPOS `mark`/`mkmk`) stacks the marks over the letter on the canvas, in PNG, SVG and PDF. Tests in `crates/text/src/vietnamese_tests.rs`. Numbers unchanged | | 2026-10-29 | minor | M9, #1415: `comment.list` (CLI, MCP `run_command`, the `figcraft://comments` resource) gives every message, the first and each reply, its `mentions`: `{name, known, you}` for each name it @mentions, by the rule the thread view highlights them with (`figcraft_doc::mentions`, which the highlighting now shares). A name resolves against the file's people and you, as the file spells it. An unknown name is reported with `known: false`. `a@b.com` mentions nobody. Filtering threads by a mentioned name and listing mentioned names in `comment.people` stay open. Numbers unchanged | | 2026-10-29 | minor | M8, #1061: a mask group's pixmaps cover only where its mask or content can paint within the view (a 40 × 40 mask on a 3000 × 2000 view: 96 MB → 61.5 kB, same pixels), and isolated layers share a 2 GiB budget held at once per render thread; a layer past it is left out with a warning. Tests: `mask_bounds_tests` | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 49429d31..fe96e65e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (MCP and control calls name their document (#998); shadows of images with transparent pixels follow their alpha (#1289); Arabic draft catalog; truncated text fills its last line before the ellipsis and keeps truncating when resized; auto-height text with max lines (#1375, #1376, #1377), numbers unchanged; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (Thai, Lao, Khmer and Burmese lines break between words (#1219); MCP and control calls name their document (#998); shadows of images with transparent pixels follow their alpha (#1289); Arabic draft catalog; truncated text fills its last line before the ellipsis and keeps truncating when resized; auto-height text with max lines (#1375, #1376, #1377), numbers unchanged; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -105,7 +105,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 7 | Effects | 4 | 100% | 75% | Glass and progressive blur tuned side by side (behaviour suite from the documentation, inner shadows and background blur on groups, shadows hidden behind translucent layers on the canvas and in every export landed) | 2–5 | | 8 | Frames, groups, sections, masks | 5 | 97% | 80% | Visual QA with real files (a one-step frame/group convert, and a behaviour suite from the documentation landed: masked-out areas don't take clicks, mask group bounds and exports follow the mask, sections kept out of frames and groups, frames and sections drawn around layers take them, section resizing leaves contents in place, X/Y measured in the frame) | 1–3 | | 9 | Auto layout & constraints | 9 | 98% | 74% | Behaviour against real Figma files (behaviour suite from the documentation, `fr` tracks like CSS, vertical wrap, auto gaps evenly/around, cell alignment and wrapped-line alignment landed) ([auto-layout-parity](auto-layout-parity.md)) | 3–6 | -| 10 | Text | 8 | 90% | 70% | Spell check, text metrics on real files and in other fonts (text box sizes measured against documents re-saved by other software: all 230 self-sizing boxes in 7 documents come out to the pixel, one font family; behaviour suite with CJK line breaking, vertical trim and carets by grapheme cluster landed; lists per paragraph with nesting and list spacing, hanging punctuation and lists, decoration style, thickness, offset, skip ink and colour landed) | 3–7 | +| 10 | Text | 8 | 90% | 70% | Spell check, text metrics on real files and in other fonts (text box sizes measured against documents re-saved by other software: all 230 self-sizing boxes in 7 documents come out to the pixel, one font family; behaviour suite with CJK line breaking, Thai, Lao, Khmer and Burmese breaks between words, vertical trim and carets by grapheme cluster landed; lists per paragraph with nesting and list spacing, hanging punctuation and lists, decoration style, thickness, offset, skip ink and colour landed) | 3–7 | | 11 | Components & variants | 9 | 96% | 72% | Real (not synthetic) libraries, `.fig` libraries (a component playground for variant, boolean, text and instance-swap properties, a behaviour suite from the documentation, overrides of padding, gap, grids, exports and interactions, refused geometry overrides, variant-switch and Assets-swap override rules, top-left default variant landed) ([components-parity](components-parity.md)) | 4–9 | | 12 | Styles & variables | 5 | 95% | 76% | Real-library QA (variables inside styles, behaviour suite from the documentation, style folders and order, mode order, code syntax, extended collections with their tail (new library variables as updates, chains through libraries) and variables import/export landed) ([components-parity](components-parity.md)) | 2–4 | | 13 | Prototyping | 6 | 92% | 74% | Video triggers and media actions, controllers on macOS, Linux and FreeBSD, real prototype QA (behaviour suite from the documentation, delays on mouse triggers, manual overlay offsets, key combinations, controller buttons on key/gamepad triggers (Windows and the browser), widgets reacting in prototypes, interactive components playing their variants, drag-driven animations, physical springs landed) ([prototyping-parity](prototyping-parity.md)) | 2–5 | @@ -296,7 +296,8 @@ Hours in this narrative are the builders' estimates; the tables above supersede decorations from the font's metrics; CJK line breaking (between ideographs, kana and Hangul, no break inside Latin words, kinsoku to JIS X 4051 for closing and opening punctuation, small kana (halfwidth too), the prolonged sound mark, the iteration marks, the (semi-)voiced sound marks - and the halfwidth parentheses); a style's metrics from its own font, not a fallback; a letter and + and the halfwidth parentheses); Thai, Lao, Khmer and Burmese lines break between words (ICU4X's word + dictionaries, its LSTM models in the browser), never after a leading vowel or before a mark; a style's metrics from its own font, not a fallback; a letter and its combining marks (Vietnamese typed decomposed, say) from one font that has them all; carets, Delete and Backspace by grapheme cluster; double-click and word moves by script run; IME composition with CJK; vertical trim (cap height to baseline). The tail suite (`crates/text/src/tail_tests.rs`, @@ -647,6 +648,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M4, #1219: Thai, Lao, Khmer and Burmese text, written without spaces between words, wraps between words instead of where a line overflows (`พัฒนาซอ|ฟต์แวร์` → `พัฒนา|ซอฟต์แวร์`). `figcraft_text::breaks::word_breaks` finds the word starts inside each run of these scripts with ICU4X's line segmenter (`icu_segmenter` 2.3): its dictionaries on desktop, its LSTM models in the browser (about 0.39 MB of wasm instead of 1.86 MB, which the 24 MiB gate can't take; they split a few compounds the dictionaries keep whole). Where a run meets spaces, Latin or CJK the UAX #14 rules decide as before, and a word wider than its box still takes #1400's emergency break. Text without these scripts does no extra work. Grade unchanged. Evidence: `breaks::tests::thai_lines_break_between_words`, `lao_khmer_and_burmese_lines_break_between_words`, `word_breaks_survive_junk`, `behaviour_tests::thai_lines_break_between_words` (fails on main). | | 2026-10-29 | minor | M8, #998: MCP tools that act on a document (`run_command`, `batch`, `create_node`, `set_properties`, `inspect_document`, `get_node`, `select`, `export`, `screenshot`, `save_file`, `undo`, `redo`) take an optional `document`, an open file's `id` (`doc.list` and the `document.inspect` outline now carry it) or path, and so do the control methods `engine.execute`, `document.inspect`, `node.get` and `ui.render`: the call acts on that file and the tab in front stays in front, so an attached agent keeps editing its own file when the user switches tabs. Calls without it act on the file in front as before; other control methods refuse a `document` behind the tab in front. Numbers unchanged | | 2026-10-29 | minor | Shadows of images with transparent pixels (M3, #1289): a drop shadow is cast from the picture's alpha and hidden only behind its pixels, not its whole box (a transparent PNG's shadow was cut away inside its bounding box), unless shown behind transparent areas; its inner shadow shades the inside of its alpha, not the box's edge; the same for Fit images, crops smaller than the box and pattern fills. Opaque pictures that fill their box keep their outline (spread still offsets it exactly); each image is scanned for transparency once. Canvas, PNG, SVG (knocked out by the layer's alpha; an inner shadow embeds a render) and PDF (`image_shadow_tests` in render and svg). Numbers unchanged | | 2026-10-28 | minor | Arabic draft: 1,857 UI strings, 620 command labels, 31 menu wordings; count-neutral plurals and web font gate. Native review and panel mirroring remain open; grade and estimates unchanged. Evidence: `crates/ui-egui/locales/ar.toml`, i18n catalog tests and [translation notes](translating.md#arabic-draft) | From 29d1c2b4d901bc71a4353808e6e406c31ba9fb5a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 29 Oct 2026 23:22:26 +0700 Subject: [PATCH 44/63] #1521: Cargo.lock keeps the craft-libs sources of craft-keymap, craft-menu and craft-quit --- Cargo.lock | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 3aa21e65..335b0d62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1010,6 +1010,7 @@ dependencies = [ [[package]] name = "craft-keymap" version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "egui", "serde", @@ -1019,6 +1020,7 @@ dependencies = [ [[package]] name = "craft-menu" version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "craft-keymap", "craft-quit", @@ -1031,6 +1033,7 @@ dependencies = [ [[package]] name = "craft-quit" version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "dispatch2", "egui", From 2e3d9b6f0752c6b923e7f778cee8b70610abd0bd Mon Sep 17 00:00:00 2001 From: BFlat Date: Thu, 29 Oct 2026 23:57:49 +0700 Subject: [PATCH 45/63] M8: SVG pattern tiles drawn once per scale, within the layer budget (#1080) (#1551) --- ROADMAP.md | 3 +- crates/svg/src/import.rs | 8 ++- crates/svg/src/import/paint.rs | 31 +++++++++-- crates/svg/src/import/tile_tests.rs | 83 +++++++++++++++++++++++++++++ docs/file-format-parity.md | 5 +- docs/roadmap.md | 3 +- 6 files changed, 122 insertions(+), 11 deletions(-) create mode 100644 crates/svg/src/import/tile_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index c4d98d90..a58f9c15 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-29 · **Change:** minor (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-10-29 · **Change:** minor (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 @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M8, #1080: SVG import draws a pattern's tile once per pattern and scale and reuses it for every other layer it fills (1,000 rectangles filled with one pattern: 1,000 tile drawings before, 1 now, same image fill), and the layers inside a tile count in the import's 100,000-layer budget instead of each tile starting a fresh one, so patterns nested in patterns stop at the budget. Tests: `tile_tests` (svg). Numbers unchanged | | 2026-10-29 | patch | M4, #1478: a letter and its combining marks are shaped with one font that has them all. Text typed decomposed (NFD, `E` + U+0302 + U+0309 for Ể) in a font with the letters but not the marks drew missing-glyph boxes beside and above the letter, while the precomposed form fell back to a font that has it; each grapheme cluster now takes the first face (the style's own, then the fallbacks) that covers every char in it (joiners and variation selectors need no glyph), else the letter's own face as before. Fonts that cover Vietnamese were already right: mark positioning (GPOS `mark`/`mkmk`) stacks the marks over the letter on the canvas, in PNG, SVG and PDF. Tests in `crates/text/src/vietnamese_tests.rs`. Numbers unchanged | | 2026-10-29 | minor | M9, #1415: `comment.list` (CLI, MCP `run_command`, the `figcraft://comments` resource) gives every message, the first and each reply, its `mentions`: `{name, known, you}` for each name it @mentions, by the rule the thread view highlights them with (`figcraft_doc::mentions`, which the highlighting now shares). A name resolves against the file's people and you, as the file spells it. An unknown name is reported with `known: false`. `a@b.com` mentions nobody. Filtering threads by a mentioned name and listing mentioned names in `comment.people` stay open. Numbers unchanged | | 2026-10-29 | minor | M8, #1061: a mask group's pixmaps (the mask, its own layer, the content and the pixels it goes onto) cover only where the mask or its content can paint (effects included) within the view, rounded out to whole pixels, instead of the whole view: a 40 × 40 mask on a 3000 × 2000 view held 96 MB, now 61.5 kB, drawing the same pixels. Isolated layers (effects, opacity, masks, clipping frames) also share one budget of 2 GiB held at once per render thread (`figcraft_render::layer_budget`); a layer past it is left out with a warning, so 64 nested full-view layers no longer ask for tens of GB. Tests: `mask_bounds_tests`. Numbers unchanged | diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index ea9c321f..e16ac4a0 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -34,8 +34,10 @@ mod paint_tests; mod shape_tests; #[cfg(test)] mod structure_tests; +#[cfg(test)] +mod tile_tests; -use std::collections::{BTreeSet, HashSet}; +use std::collections::{BTreeSet, HashMap, HashSet}; use figcraft_color::{BlendMode, ImageScaleMode, Paint}; use figcraft_doc::{Document, Effect, MaskType, Node, NodeId, NodeKind, StrokeAlign, StrokeCap, StrokeJoin, VectorNetwork, WindingRule}; @@ -96,11 +98,13 @@ pub(crate) struct Ctx<'a> { fonts: Option, /// Patterns being drawn into tile images (inside one another). pattern_depth: usize, + /// Tiles drawn so far, by pattern and scale (see [`paint`]'s `render_tile`). + tiles: HashMap<(*const usvg::Pattern, u64), Option>, } impl<'a> Ctx<'a> { pub(crate) fn new(doc: &'a mut Document) -> Self { - Ctx { doc, count: 0, anonymous: HashSet::new(), notes: BTreeSet::new(), fonts: None, pattern_depth: 0 } + Ctx { doc, count: 0, anonymous: HashSet::new(), notes: BTreeSet::new(), fonts: None, pattern_depth: 0, tiles: HashMap::new() } } pub(crate) fn full(&self) -> bool { diff --git a/crates/svg/src/import/paint.rs b/crates/svg/src/import/paint.rs index ebee6692..6e6300cc 100644 --- a/crates/svg/src/import/paint.rs +++ b/crates/svg/src/import/paint.rs @@ -228,17 +228,36 @@ fn unstale(root: &usvg::Group) -> (&usvg::Group, Affine) { (g, base) } +/// A pattern tile drawn into an image: the image's hash, its size and its pixels → pattern space +/// transform. +pub(crate) type Tile = (String, f64, f64, Affine); + /// Draws pattern `pat`'s tile into an image (at twice its size on the page, at most [`MAX_TILE`] -/// a side): the image's hash, its size and its pixels → pattern space transform. -fn render_tile(cx: &mut Ctx, pat: &usvg::Pattern, tile: Affine) -> Option<(String, f64, f64, Affine)> { +/// a side), once per pattern and scale in an import: other uses get the same tile. +fn render_tile(cx: &mut Ctx, pat: &usvg::Pattern, tile: Affine) -> Option { if cx.pattern_depth >= 2 { return None; } let r = pat.rect(); let (rw, rh) = (f64::from(r.width()), f64::from(r.height())); let k = (length_scale(tile) * 2.0).max(0.01).min(MAX_TILE / rw.max(rh)); + // The tile depends only on the pattern and the scale; the tree outlives the import, so its + // address names the pattern (ids repeat across embedded documents). + let key = (std::ptr::from_ref(pat), k.to_bits()); + if let Some(t) = cx.tiles.get(&key) { + return t.clone(); + } + let t = draw_tile(cx, pat, k); + cx.tiles.insert(key, t.clone()); + t +} + +/// [`render_tile`]'s drawing at scale `k`, its layers counted in the import's layer budget. +fn draw_tile(cx: &mut Ctx, pat: &usvg::Pattern, k: f64) -> Option { + let r = pat.rect(); + let (rw, rh) = (f64::from(r.width()), f64::from(r.height())); let (pw, ph) = ((rw * k).ceil().max(1.0), (rh * k).ceil().max(1.0)); - if !(pw.is_finite() && ph.is_finite()) || pw > MAX_TILE || ph > MAX_TILE { + if !(pw.is_finite() && ph.is_finite()) || pw > MAX_TILE || ph > MAX_TILE || cx.full() { return None; } let mut sub = Document::new("Pattern"); @@ -247,13 +266,15 @@ fn render_tile(cx: &mut Ctx, pat: &usvg::Pattern, tile: Affine) -> Option<(Strin if let Some(f) = sub.node_mut(frame) { f.fills.clear(); } - let notes = { + let (notes, count) = { let mut scx = Ctx::new(&mut sub); + scx.count = cx.count; scx.pattern_depth = cx.pattern_depth + 1; let (content, base) = unstale(pat.root()); children(&mut scx, content, frame, base, 0); - std::mem::take(&mut scx.notes) + (std::mem::take(&mut scx.notes), scx.count) }; + cx.count = count; cx.notes.extend(notes); let view = figcraft_render::View { origin: (0.0, 0.0), scale: k, width: pw as u32, height: ph as u32 }; let mut pix = figcraft_render::Pixmap::new(pw as u32, ph as u32)?; diff --git a/crates/svg/src/import/tile_tests.rs b/crates/svg/src/import/tile_tests.rs new file mode 100644 index 00000000..42d45cf7 --- /dev/null +++ b/crates/svg/src/import/tile_tests.rs @@ -0,0 +1,83 @@ +//! Pattern tiles: drawn once per pattern and scale in an import, their layers counted in the +//! import's layer budget. + +use figcraft_color::{Paint, PaintKind}; +use figcraft_doc::{Document, NodeKind}; +use figcraft_geom::Affine; + +use super::{Ctx, MAX_NODES, children, import_svg_with_notes}; + +fn svg(defs: &str, body: &str) -> String { + format!(r##"{defs}{body}"##) +} + +/// What importing `data` with `spent` layers of the budget already used leaves behind: the tiles +/// drawn, the layers counted and the notes. +fn walk(data: &str, spent: usize) -> (usize, usize, Vec) { + let tree = usvg::Tree::from_data(data.as_bytes(), &usvg::Options::default()).unwrap(); + let mut d = Document::new("tiles"); + let page = d.first_page().unwrap(); + let frame = d.create(page, NodeKind::Frame, "f", 0.0, 0.0, 100.0, 100.0).unwrap(); + let mut cx = Ctx::new(&mut d); + cx.count = spent; + children(&mut cx, tree.root(), frame, Affine::IDENTITY, 0); + (cx.tiles.len(), cx.count, cx.notes.into_iter().collect()) +} + +const DOTS: &str = r##""##; + +#[test] +fn a_pattern_used_a_thousand_times_is_drawn_once() { + let uses: String = (0..1000).map(|i| format!(r#""#, i % 10 * 10, i / 10)).collect(); + let (tiles, count, _) = walk(&svg(DOTS, &uses), 0); + assert_eq!(tiles, 1); + // A thousand rectangles and the tile's three layers, once. + assert_eq!(count, 1003); + // Another scale is another tile. + let scaled = format!(r#"{uses}"#); + assert_eq!(walk(&svg(DOTS, &scaled), 0).0, 2); +} + +/// The image fills of `data`'s layers, and the images they show. +fn fills(data: &str) -> Vec<(Paint, Vec)> { + let mut d = Document::new("fills"); + let page = d.first_page().unwrap(); + let f = import_svg_with_notes(&mut d, data.as_bytes(), page, 0.0, 0.0, "x").unwrap().frame; + let layers: Vec<_> = d.subtree(f).into_iter().filter_map(|id| d.node(id)).filter_map(|n| n.fills.first().cloned()).collect(); + layers + .into_iter() + .map(|p| { + let PaintKind::Image(ip) = &p.kind else { panic!("not an image fill: {p:?}") }; + let bytes = d.images.get(&ip.image).unwrap().bytes.to_vec(); + (p, bytes) + }) + .collect() +} + +#[test] +fn a_reused_tile_is_the_tile_a_single_use_draws() { + let once = fills(&svg(DOTS, r#""#)); + let twice = fills(&svg(DOTS, r#""#)); + assert_eq!(once.len(), 1); + assert_eq!(twice.len(), 2); + assert!(twice.iter().all(|f| *f == once[0]), "both uses show the single use's tile"); +} + +#[test] +fn a_nested_pattern_bomb_is_refused_within_the_budget() { + // Pattern `a` holds 50 layers, each filled with pattern `b` at its own scale (a tile each), + // and `b` holds 50 layers: 2,601 layers from one rectangle (each of `a`'s layers comes with + // the group carrying its transform). + let b: String = (0..50).map(|_| r#""#).collect(); + let a: String = (1..=50).map(|i| format!(r#""#)).collect(); + let defs = format!( + r#"{b}{a}"# + ); + let data = svg(&defs, r#""#); + // Every tile's layers count. + assert_eq!(walk(&data, 0).1, 2601); + // With 500 layers left the tiles stop there. + let (_, count, notes) = walk(&data, MAX_NODES - 500); + assert!(count <= MAX_NODES, "{count}"); + assert!(notes.iter().any(|n| n.contains("the rest was left out")), "{notes:?}"); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index f44e2296..fc779ba0 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-10-29 · **Change:** minor (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-10-29 · **Change:** minor (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 @@ -30,7 +30,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft | Notes | Hours | |---|---|---|---|---| -| SVG | editable vectors | **yes**, editable | corpus of 21 synthetic files with hand-computed expected pixels, structure and round-trip checks; gradients, patterns as image fills, clip paths, masks, shadow/blur filters, blend modes, text as text layers; other filters dropped with a warning; text on one path as text on a path (on several paths as outlines); fuzzed | 2–4 (filters) | +| SVG | editable vectors | **yes**, editable | corpus of 21 synthetic files with hand-computed expected pixels, structure and round-trip checks; gradients, patterns as image fills, clip paths, masks, shadow/blur filters, blend modes, text as text layers; other filters dropped with a warning; text on one path as text on a path (on several paths as outlines); a pattern's tile is drawn once per scale however many layers use it, and its layers count in the import's 100,000-layer budget; fuzzed | 2–4 (filters) | | PNG, JPG | yes | yes | embedded ICC profiles (PNG `iCCP`, JPG APP2) and PNG `sRGB` / `gAMA` / `cHRM` chunks convert the image into the file's colour space; no profile (or CMYK, damaged): taken as sRGB | — | | GIF | yes (animated) | **yes**, animated | the file is kept as is; the canvas and PNG, JPG and PDF exports show the first frame (SVG exports embed the file, so viewers that animate GIFs animate it), the presenter and the preview play it (frames decoded on first play: at most 500 frames and 128 MB, past that the rest don't play; always loops) | — | | WebP | yes | yes | embedded ICC profile honoured | — | @@ -184,6 +184,7 @@ source paint coordinates. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M8, #1080: SVG import draws a pattern's tile once per pattern and scale and reuses it for every other layer it fills (1,000 rectangles filled with one pattern: 1,000 tile drawings before, 1 now, same image fill), and the layers inside a tile count in the import's 100,000-layer budget instead of each tile starting a fresh one, so patterns nested in patterns stop at the budget. Tests: `tile_tests` (svg). Numbers unchanged | | 2026-10-29 | patch | M8: the progressive JPEG check from #1491 (a frame sampling a component more finely than its first, which the JPEG decoder asserts on, and in the browser a panic aborts the page) moved to `figcraft_format::jpeg` and now also guards the pictures files carry (`thumbnail::decode_thumbnail`: the home screen and recent files), so a crafted `.figcraft`, `.fig` or `.sketch` thumbnail or image file shows no picture instead of crashing. The canvas, exports and SVG/PDF keep it through the renderer. Regression tests in `jpeg_tests` (format) and `jpeg_sampling_tests` (render). Numbers unchanged | | 2026-10-29 | minor | Fonts: the missing-font row adds downloading a missing family the font store has (Google Fonts, Noto) from the Missing fonts window, after the user allows it (#754). Replacing a missing font with another is still missing. Numbers unchanged | | 2026-10-29 | minor | Image key names (#1086): one rule now covers the archive reader, the collab entries and the REST importer's placeholders — 1 to 128 ASCII letters, digits, `-` or `_`. The reader accepts `-` and `_` from now on, so the REST importer's `rest-image-missing` placeholder survives a save and load instead of being dropped as damaged; a placeholder whose `imageRef` can't name an archive member (path text, an over-long ref) or is shaped like a content hash (64 hex digits, which the placeholder's bytes don't hash to) now shares that placeholder instead of being keyed by its raw name. The importers' largest image (256 MiB) is one constant, `figcraft_doc::ImageData::MAX_BYTES`, which live sessions use too. Numbers unchanged | diff --git a/docs/roadmap.md b/docs/roadmap.md index 64736092..2e500c03 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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-10-29 · **Change:** minor (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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M8, #1080: SVG import draws a pattern's tile once per pattern and scale and reuses it for every other layer it fills, and the layers inside a tile count in the import's 100,000-layer budget instead of each tile starting a fresh one. Tests: `tile_tests` (svg) | | 2026-10-29 | patch | M4, #1478: a letter and its combining marks are shaped with one font that has them all. Text typed decomposed (NFD, `E` + U+0302 + U+0309 for Ể) in a font with the letters but not the marks drew missing-glyph boxes beside and above the letter, while the precomposed form fell back to a font that has it; each grapheme cluster now takes the first face (the style's own, then the fallbacks) that covers every char in it (joiners and variation selectors need no glyph), else the letter's own face as before. Fonts that cover Vietnamese were already right: mark positioning (GPOS `mark`/`mkmk`) stacks the marks over the letter on the canvas, in PNG, SVG and PDF. Tests in `crates/text/src/vietnamese_tests.rs`. Numbers unchanged | | 2026-10-29 | minor | M9, #1415: `comment.list` (CLI, MCP `run_command`, the `figcraft://comments` resource) gives every message, the first and each reply, its `mentions`: `{name, known, you}` for each name it @mentions, by the rule the thread view highlights them with (`figcraft_doc::mentions`, which the highlighting now shares). A name resolves against the file's people and you, as the file spells it. An unknown name is reported with `known: false`. `a@b.com` mentions nobody. Filtering threads by a mentioned name and listing mentioned names in `comment.people` stay open. Numbers unchanged | | 2026-10-29 | minor | M8, #1061: a mask group's pixmaps cover only where its mask or content can paint within the view (a 40 × 40 mask on a 3000 × 2000 view: 96 MB → 61.5 kB, same pixels), and isolated layers share a 2 GiB budget held at once per render thread; a layer past it is left out with a warning. Tests: `mask_bounds_tests` | From 59af2bc44a16b41b112f40a8831dc58e4185bdc6 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 00:06:49 +0700 Subject: [PATCH 46/63] #1547: keep the default board dots' size, and test the Preferences grid section --- crates/ui-egui/src/board_ui_tests.rs | 29 ++++++++++++++++++++++++++ crates/ui-egui/src/canvas/board_bar.rs | 6 +++--- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/board_ui_tests.rs b/crates/ui-egui/src/board_ui_tests.rs index f1216d77..7926dfd2 100644 --- a/crates/ui-egui/src/board_ui_tests.rs +++ b/crates/ui-egui/src/board_ui_tests.rs @@ -308,3 +308,32 @@ fn the_board_grid_follows_its_settings() { assert_eq!(grid_spacing(1e-320, 512.0, 2, 14.0), None, "no finite step that far out"); assert!(grid_spacing(1e-300, 4.0, 12, 10.0).is_some_and(|(step, _)| step.is_finite())); } + +#[test] +fn preferences_set_the_board_grid_and_the_default_dots_keep_their_size() { + let mut u = board(); + let bg = egui::Color32::from_rgb(240, 240, 240); + // The default grid: dots a point and a half across at 100 %, one to three points on screen. + for (zoom, on_screen) in [(1.0, 1.5), (0.25, 1.0), (4.0, 3.0)] { + u.app.session.ds_mut().unwrap().view.zoom = zoom; + let t = crate::theme::Tokens::get(&u.ctx); + let size = crate::board_bar::dots(&u.app, bg, &t).unwrap().size; + assert!((size * zoom - on_screen).abs() < 1e-9, "at {zoom}: {size}"); + } + u.app.session.ds_mut().unwrap().view.zoom = 1.0; + u.app.ui.preferences = true; + u.frame(vec![]); + let shapes = flat(&u.frame_output(vec![]).shapes); + assert!(has_text(&shapes, "Board grid") && has_text(&shapes, "Spacing")); + assert!(!has_text(&shapes, "Strong line every") && !has_text(&shapes, "Reset grid"), "dots, as usual"); + click(&mut u, text_center(&shapes, "Graph paper")); + assert_eq!(u.app.session.prefs.board_grid.style, figcraft_engine::board_grid::GridStyle::Graph); + let shapes = flat(&u.frame_output(vec![]).shapes); + assert!(has_text(&shapes, "Strong line every"), "graph paper's cells per strong line"); + click(&mut u, text_center(&shapes, "Reset grid")); + assert_eq!(u.app.session.prefs.board_grid, Default::default()); + // No grid: nothing to set but the style. + u.app.run("board.grid", json!({"style": "none"})); + let shapes = flat(&u.frame_output(vec![]).shapes); + assert!(has_text(&shapes, "Board grid") && !has_text(&shapes, "Spacing")); +} diff --git a/crates/ui-egui/src/canvas/board_bar.rs b/crates/ui-egui/src/canvas/board_bar.rs index f7add740..d7a03e0b 100644 --- a/crates/ui-egui/src/canvas/board_bar.rs +++ b/crates/ui-egui/src/canvas/board_bar.rs @@ -321,10 +321,10 @@ pub fn dots(app: &FigApp, bg: Color32, t: &Tokens) -> Option Date: Fri, 30 Oct 2026 00:06:50 +0700 Subject: [PATCH 47/63] #1547: drop the board grid entry from the right-click menu --- crates/ui-egui/locales/en.toml | 1 - crates/ui-egui/src/menus.rs | 4 ---- docs/boards-parity.md | 4 ++-- 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 2f22e76c..cf5d0f97 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -184,7 +184,6 @@ name = "English" "Blue" = "Blue" "Blur" = "Blur" "Board grid" = "Board grid" -"Board grid settings…" = "Board grid settings…" "Boolean" = "Boolean" "Boolean groups" = "Boolean groups" "Both directions" = "Both directions" diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index f10283b3..76f220f4 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -554,10 +554,6 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { { entries[at] = (tr("Go to main component"), "component.goToMain"); } - // On a board, the background grid's settings (Preferences ▸ Board grid). - if !has_sel && app.session.doc().is_ok_and(figcraft_engine::board::is_board) { - entries.extend([("-", ""), (tr("Board grid settings…"), "app.preferences")]); - } // A right-clicked ruler guide (selected, with no layers) can be removed. if !has_sel && app.session.ds().is_ok_and(|d| d.selected_guide().is_some()) { entries.splice(0..0, [(tr("Remove guide"), "guide.remove"), ("-", "")]); diff --git a/docs/boards-parity.md b/docs/boards-parity.md index 82c32367..ff94b0a0 100644 --- a/docs/boards-parity.md +++ b/docs/boards-parity.md @@ -56,14 +56,14 @@ all end with the same geometry. | Washi tape, code blocks, image cropping on boards | missing | images can be placed as in design files | | Audio and huddles | missing | | | AI features (summaries, sorting stickies, generating boards) | missing | | -| Grid on the page | done | the canvas render draws it after the page colour and before the layers (`figcraft_render::Dots`), so objects cover it. Dots by default, or lines, graph paper (a strong line every 2–12 cells over fainter ones, like Japanese section paper) or none; spacing (4–512), mark size and contrast are settings kept with the preferences (`board.grid`; Preferences ▸ Board grid, or right-click the board ▸ Board grid settings…). Zoomed out it thins out (dots and lines by halves, graph paper down to its strong lines) until the marks are at least 14 points apart (10 for lines), the marks the next step drops fading out first; dots never shrink below their size at 100 %. Dark marks on a light page, light ones on a dark page, from the theme's ink; written straight into the image, no allocation per mark, a crossing of two lines blended once; never in exports. FigCraft's own settings: the incumbent's board grid has none | +| Grid on the page | done | the canvas render draws it after the page colour and before the layers (`figcraft_render::Dots`), so objects cover it. Dots by default, or lines, graph paper (a strong line every 2–12 cells over fainter ones, like Japanese section paper) or none; spacing (4–512), mark size and contrast are settings kept with the preferences (`board.grid`; Preferences ▸ Board grid). Zoomed out it thins out (dots and lines by halves, graph paper down to its strong lines) until the marks are at least 14 points apart (10 for lines), the marks the next step drops fading out first; dots stay about a point and a half across on screen (1 to 3 points), scaled by the size setting. Dark marks on a light page, light ones on a dark page, from the theme's ink; written straight into the image, no allocation per mark, a crossing of two lines blended once; never in exports. FigCraft's own settings: the incumbent's board grid has none | | `.jam` import and export | missing | see [`file-format-parity.md`](file-format-parity.md) | ## Revision history | Date | Change | Summary | |---|---|---| -| 2026-10-29 | minor | M12: board grid settings (`board.grid`): dots, lines, graph paper or none, with spacing, size and contrast, in Preferences and the board's right-click menu; zoomed-out dots no longer shrink to a pixel. Numbers unchanged (beyond the incumbent's behaviour) | +| 2026-10-29 | minor | M12: board grid settings (`board.grid`): dots, lines, graph paper or none, with spacing, size and contrast, in Preferences; the default grid looks as before. Numbers unchanged (beyond the incumbent's behaviour) | | 2026-10-29 | minor | M12, #1387 (connector tool): while a connector is dragged out with the connector tool, the canvas shows the connector itself as it will be made (its straight, elbow or curved route, line, weight and arrowhead, built by the same code as `board.connector`), not a thin line; the object under the pointer is outlined with its four attach points (the middles of its sides), the one the end goes to ringed; within 10 px of one the end keeps that side (`fromSide`/`toSide`, and `side` when re-attaching). Hovering with the tool shows the attach points too. Escape drops the preview; releasing makes the connector in one undo step. Tests: `board_connector_tests`. Numbers unchanged | | 2026-10-24 | minor | M12: the dot grid on board pages (a render pass under the layers), dragging a selected connector's end onto another object or empty canvas (`board.reattach`), curved connectors, and elbows that go around the objects they join. Breadth ~35% → ~37%, ready ~20% → ~21%, ~30–60 h → ~28–56 h | | 2026-10-24 | minor | Facilitation (M12): a shared timer and voting sessions. Breadth ~35% → ~40%, ready ~20% → ~24%, ~30–60 h → ~26–54 h | From b00cccb1144c958b2dad0825fa8f79d82cf8cba5 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 00:13:46 +0700 Subject: [PATCH 48/63] #1547: name the grid styles in a table, keeping the Preferences section's complexity low --- crates/ui-egui/src/canvas/board_bar.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/canvas/board_bar.rs b/crates/ui-egui/src/canvas/board_bar.rs index d7a03e0b..8a33538c 100644 --- a/crates/ui-egui/src/canvas/board_bar.rs +++ b/crates/ui-egui/src/canvas/board_bar.rs @@ -357,14 +357,14 @@ pub(crate) fn grid_settings(app: &mut FigApp, ui: &mut Ui) { let mut set = None; ui.horizontal(|ui| { ui.label(tr("Board grid")); - for style in GridStyle::ALL { - let label = match style { - GridStyle::Dots => tr("Dots"), - GridStyle::Lines => tr("Lines"), - GridStyle::Graph => tr("Graph paper"), - GridStyle::None => tr("None"), - }; - if ui.selectable_label(g.style == style, label).clicked() { + let styles = [ + (GridStyle::Dots, tr_noop("Dots")), + (GridStyle::Lines, tr_noop("Lines")), + (GridStyle::Graph, tr_noop("Graph paper")), + (GridStyle::None, tr_noop("None")), + ]; + for (style, label) in styles { + if ui.selectable_label(g.style == style, tr(label)).clicked() { set = Some(json!({"style": style.id()})); } } From f6ccef0c5d07ffefaa2164a945e517e9e982e9a7 Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 30 Oct 2026 00:20:57 +0700 Subject: [PATCH 49/63] M9: a per-address budget for server admin requests, and join codes found by hash (#1075) (#1552) * M9: a per-address budget for server admin requests, and join codes found by hash (#1075) * M9: the server tests share one snapshot and address helper (#1075) --- ROADMAP.md | 3 +- apps/figcraft-server/src/abuse.rs | 12 ++- .../figcraft-server/src/admin_budget_tests.rs | 75 +++++++++++++++++++ apps/figcraft-server/src/cli.rs | 3 +- apps/figcraft-server/src/codes_tests.rs | 16 +--- apps/figcraft-server/src/hardening_tests.rs | 14 +--- apps/figcraft-server/src/lib.rs | 2 + apps/figcraft-server/src/public_tests.rs | 16 +--- apps/figcraft-server/src/store.rs | 12 ++- apps/figcraft-server/src/store/backend.rs | 21 +++++- apps/figcraft-server/src/store/links.rs | 30 ++++++-- apps/figcraft-server/src/test_util.rs | 14 ++++ crates/collab-net/src/hub.rs | 6 +- crates/collab-net/src/hub/backend.rs | 5 +- docs/collaboration-parity.md | 3 +- docs/roadmap.md | 3 +- docs/server.md | 2 +- 17 files changed, 170 insertions(+), 67 deletions(-) create mode 100644 apps/figcraft-server/src/admin_budget_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index a58f9c15..d05353d3 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-29 · **Change:** minor (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-10-29 · **Change:** minor (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); 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 @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M9, #1075: `figcraft-server` gives requests that change its files (join codes, links, renames, removals, blocks, publishing with the key) a budget of 60 a minute per address, with the server key and owner tokens alike (100 renames in a row from one address: 100 served before, 60 now; another address is unaffected), and finds join codes through an index of their hashes instead of opening every link's code. Same results, same protocol. Tests: `admin_budget_tests`. Numbers unchanged | | 2026-10-29 | minor | M8, #1080: SVG import draws a pattern's tile once per pattern and scale and reuses it for every other layer it fills (1,000 rectangles filled with one pattern: 1,000 tile drawings before, 1 now, same image fill), and the layers inside a tile count in the import's 100,000-layer budget instead of each tile starting a fresh one, so patterns nested in patterns stop at the budget. Tests: `tile_tests` (svg). Numbers unchanged | | 2026-10-29 | patch | M4, #1478: a letter and its combining marks are shaped with one font that has them all. Text typed decomposed (NFD, `E` + U+0302 + U+0309 for Ể) in a font with the letters but not the marks drew missing-glyph boxes beside and above the letter, while the precomposed form fell back to a font that has it; each grapheme cluster now takes the first face (the style's own, then the fallbacks) that covers every char in it (joiners and variation selectors need no glyph), else the letter's own face as before. Fonts that cover Vietnamese were already right: mark positioning (GPOS `mark`/`mkmk`) stacks the marks over the letter on the canvas, in PNG, SVG and PDF. Tests in `crates/text/src/vietnamese_tests.rs`. Numbers unchanged | | 2026-10-29 | minor | M9, #1415: `comment.list` (CLI, MCP `run_command`, the `figcraft://comments` resource) gives every message, the first and each reply, its `mentions`: `{name, known, you}` for each name it @mentions, by the rule the thread view highlights them with (`figcraft_doc::mentions`, which the highlighting now shares). A name resolves against the file's people and you, as the file spells it. An unknown name is reported with `known: false`. `a@b.com` mentions nobody. Filtering threads by a mentioned name and listing mentioned names in `comment.people` stay open. Numbers unchanged | diff --git a/apps/figcraft-server/src/abuse.rs b/apps/figcraft-server/src/abuse.rs index 2ea939c3..05eb5122 100644 --- a/apps/figcraft-server/src/abuse.rs +++ b/apps/figcraft-server/src/abuse.rs @@ -1,5 +1,6 @@ //! What one address may do how often: new connections, publishing without the key and messages -//! (only on a server open to the public), and reports and downloads (always). Each is a token +//! (only on a server open to the public), and reports, downloads and requests that change the +//! server's files (always, with the server key too). Each is a token //! bucket per block an address counts in ([`ip_blocks`]: an IPv4 address, or an IPv6 /64 and its //! /56 and /48 with 2 and 4 times the rate), so a tunnel's /48 doesn't get 65,536 /64s' worth. Publishing has a budget for //! everyone besides ([`PUBLISHES_ALL`] times one address's). The table keeps at most @@ -34,6 +35,10 @@ pub const DOWNLOAD_BURST: f64 = (16u64 << 20) as f64; /// times what the widest block (an IPv6 /48, four addresses' worth) may, so no block takes it all. pub const PUBLISHES_ALL: f64 = 32.0; +/// Requests that change the server's files (join codes, links, renames, removals, blocks, +/// publishing with the key) one address may make a minute, with the server key or an owner token. +pub const ADMIN_PER_MINUTE: f64 = 60.0; + /// Join codes one address may try a minute (right or wrong). pub const CODES_PER_MINUTE: f64 = 10.0; /// Wrong codes in a row (within [`CODE_LOCKOUT`] of each other) that lock an address out of codes. @@ -66,6 +71,8 @@ pub enum Action { Download(usize), /// Trying a join code. Code, + /// A request that changes the server's files ([`ADMIN_PER_MINUTE`]). + Admin, } struct Address { @@ -76,6 +83,7 @@ struct Address { reports: Bucket, downloads: Bucket, codes: Bucket, + admin: Bucket, /// Wrong join codes in a row, and when the last one was. wrong: u32, wrong_at: Instant, @@ -177,6 +185,7 @@ impl Abuse { reports: bucket(REPORTS_PER_HOUR * times, 3600.0), downloads: Bucket::new(DOWNLOAD_RATE * times, DOWNLOAD_BURST * times), codes: bucket(CODES_PER_MINUTE * times, 60.0), + admin: bucket(ADMIN_PER_MINUTE * times, 60.0), wrong: 0, wrong_at: now, locked: None, @@ -195,6 +204,7 @@ impl Abuse { Action::Report => (&mut a.reports, 1.0), Action::Download(n) => (&mut a.downloads, n as f64), Action::Code => (&mut a.codes, 1.0), + Action::Admin => (&mut a.admin, 1.0), } } diff --git a/apps/figcraft-server/src/admin_budget_tests.rs b/apps/figcraft-server/src/admin_budget_tests.rs new file mode 100644 index 00000000..cee514b6 --- /dev/null +++ b/apps/figcraft-server/src/admin_budget_tests.rs @@ -0,0 +1,75 @@ +//! Requests that change the server's files have a budget per address, with the server key or an +//! owner token too; join codes are found through an index of their hashes, with the same results +//! as comparing every link. + +use std::net::IpAddr; + +use figcraft_collab::message::{Admin, AdminReply}; +use figcraft_collab_net::hub::Backend; + +use crate::abuse::ADMIN_PER_MINUTE; +use crate::store::{BUSY_ADMIN, CODE_GONE, Store}; +use crate::test_util::{TempDir, config, ip, snapshot}; + +fn rename(store: &mut Store, from: IpAddr, file: &str) -> AdminReply { + Backend::admin(store, from, Admin::Rename { file: file.into(), name: "Renamed".into() }, &|_| None).reply +} + +#[test] +fn a_flood_of_changes_from_one_address_is_held_back_and_others_are_not() { + let dir = TempDir::new("admin-budget"); + let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap(); + let (file, ..) = store.publish("Poster", &snapshot("Poster")).unwrap(); + let flooder = ip("198.51.100.1"); + let done = (0..100).filter(|_| rename(&mut store, flooder, &file.id) == AdminReply::Done).count(); + assert_eq!(done, ADMIN_PER_MINUTE as usize, "a minute's worth, then wait"); + assert_eq!(rename(&mut store, flooder, &file.id), AdminReply::Failed { reason: BUSY_ADMIN.into() }); + // An owner token's requests share the address's budget. + let hosted = store.hosted(flooder, Some(&file.id), Admin::Rename { file: file.id.clone(), name: "Mine".into() }, &|_| None).reply; + assert_eq!(hosted, AdminReply::Failed { reason: BUSY_ADMIN.into() }); + // Looking costs nothing, and another address has its own budget. + assert!(matches!(Backend::admin(&mut store, flooder, Admin::Files, &|_| None).reply, AdminReply::Files(_))); + assert_eq!(rename(&mut store, ip("198.51.100.2"), &file.id), AdminReply::Done); +} + +#[test] +fn codes_are_found_through_the_index_as_before() { + let dir = TempDir::new("code-index"); + let files_dir = dir.path().join("files"); + let (files, codes) = { + let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap(); + let mut files = Vec::new(); + let mut codes = Vec::new(); + for n in 0..12 { + let (file, edit, _view) = store.publish(&format!("File {n}"), &snapshot("File")).unwrap(); + codes.push(store.set_code(&file.id, &edit.token).unwrap()); + files.push((file.id, edit.token, edit.link.id)); + } + (files, codes) + }; + // A copy of a file's folder holds the same code (the last file by id wins, as before). + let copy = "zzzzzzzzzzzzzzzz"; + std::fs::create_dir_all(files_dir.join(copy)).unwrap(); + for f in std::fs::read_dir(files_dir.join(&files[0].0)).unwrap() { + let f = f.unwrap(); + std::fs::copy(f.path(), files_dir.join(copy).join(f.file_name())).unwrap(); + } + let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap(); + let who = |n: usize| ip(&format!("192.0.2.{n}")); + for (n, ((id, token, _), code)) in files.iter().zip(&codes).enumerate().skip(1) { + assert_eq!(store.resolve_code(who(n), code).unwrap(), (id.clone(), token.clone()), "file {n}"); + } + assert_eq!(store.resolve_code(who(100), &codes[0]).unwrap().0, copy); + // Revoking the copy's link leaves the original's code working; revoking that one ends it. + store.revoke(copy, &files[0].2).unwrap(); + assert_eq!(store.resolve_code(who(101), &codes[0]).unwrap(), (files[0].0.clone(), files[0].1.clone())); + store.revoke(&files[0].0, &files[0].2).unwrap(); + assert_eq!(store.resolve_code(who(102), &codes[0]).unwrap_err(), CODE_GONE); + // A new code replaces the old in the index; a removed file's code is gone. + let newer = store.set_code(&files[1].0, &files[1].1).unwrap(); + assert_eq!(store.resolve_code(who(103), &codes[1]).unwrap_err(), CODE_GONE); + assert_eq!(store.resolve_code(who(104), &newer).unwrap().0, files[1].0); + store.remove(&files[2].0).unwrap(); + assert_eq!(store.resolve_code(who(105), &codes[2]).unwrap_err(), CODE_GONE); + assert_eq!(store.resolve_code(who(106), &codes[3]).unwrap().0, files[3].0); +} diff --git a/apps/figcraft-server/src/cli.rs b/apps/figcraft-server/src/cli.rs index f0bd5929..d92689df 100644 --- a/apps/figcraft-server/src/cli.rs +++ b/apps/figcraft-server/src/cli.rs @@ -316,7 +316,8 @@ fn running(data: &Path) -> Result { /// Do one request: through the running server, or on the data directory. fn admin(cfg: &Config, req: Admin) -> Result { match Store::try_open(&cfg.data, cfg.store())? { - Some(mut store) => Ok(store.admin(req, &|_| None).reply), + // On this machine, one request per command: never over the address's budget. + Some(mut store) => Ok(store.admin(std::net::Ipv4Addr::LOCALHOST.into(), req, &|_| None).reply), None => { let addr = running(&cfg.data)?; let key = std::fs::read_to_string(cfg.data.join(KEY)).map_err(|e| format!("can't read the server key: {e}"))?; diff --git a/apps/figcraft-server/src/codes_tests.rs b/apps/figcraft-server/src/codes_tests.rs index 1e58f7c2..e226d2ec 100644 --- a/apps/figcraft-server/src/codes_tests.rs +++ b/apps/figcraft-server/src/codes_tests.rs @@ -7,11 +7,9 @@ use std::net::IpAddr; use std::time::{Duration, Instant}; -use figcraft_collab::Replica; use figcraft_collab::code; use figcraft_collab::message::{Admin, AdminReply, ClientMsg, Hello, OpenHosting, PROTOCOL, Resolve, ServerMsg}; use figcraft_collab_net::hub::Backend; -use figcraft_doc::{Document, NodeKind}; use figcraft_engine::Session; use figcraft_engine::live::Status; use serde_json::{Value, json}; @@ -20,24 +18,12 @@ use crate::abuse::{Abuse, AbuseConfig, Action, CODE_LOCKOUT, CODES_PER_MINUTE, W use crate::codes::Key; use crate::config::Config; use crate::store::{CODE_GONE, CODES_BUSY, NO_CODE, Store}; -use crate::test_util::{Raw, TempDir, config, live, pump, session}; +use crate::test_util::{Raw, TempDir, config, ip, live, pump, session, snapshot}; fn open(dir: &std::path::Path) -> Config { Config { open_publish: true, operator_contact: "abuse@example.com".into(), terms_url: "https://example.com/terms".into(), ..config(dir) } } -fn snapshot(name: &str) -> Vec { - let mut d = Document::new(name); - let page = d.first_page().unwrap(); - d.create(page, NodeKind::Rectangle, "Card", 0.0, 0.0, 100.0, 60.0).unwrap(); - d.repair(); - Replica::new(d).snapshot().unwrap() -} - -fn ip(s: &str) -> IpAddr { - s.parse().unwrap() -} - fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) } diff --git a/apps/figcraft-server/src/hardening_tests.rs b/apps/figcraft-server/src/hardening_tests.rs index 92b17710..8226aa3f 100644 --- a/apps/figcraft-server/src/hardening_tests.rs +++ b/apps/figcraft-server/src/hardening_tests.rs @@ -25,24 +25,12 @@ use crate::abuse::{Abuse, AbuseConfig, Action, DOWNLOAD_BURST}; use crate::config::Config; use crate::public_tests::via_proxy; use crate::store::{BUSY_DOWNLOADS, NO_PUBLIC_LIBRARY, Store}; -use crate::test_util::{Raw, TempDir, config}; +use crate::test_util::{Raw, TempDir, config, ip, snapshot}; fn open(dir: &std::path::Path) -> Config { Config { open_publish: true, operator_contact: "abuse@example.com".into(), ..config(dir) } } -fn snapshot(name: &str) -> Vec { - let mut d = Document::new(name); - let page = d.first_page().unwrap(); - d.create(page, NodeKind::Rectangle, "Card", 0.0, 0.0, 100.0, 60.0).unwrap(); - d.repair(); - Replica::new(d).snapshot().unwrap() -} - -fn ip(s: &str) -> IpAddr { - s.parse().unwrap() -} - /// A team library archive as the app makes it; with `bomb` bytes of spaces as its snapshot /// (deflated: a few KiB) and `padding` incompressible bytes stored beside it. fn archive(bomb: usize, padding: usize) -> Vec { diff --git a/apps/figcraft-server/src/lib.rs b/apps/figcraft-server/src/lib.rs index e2f0d440..746205d1 100644 --- a/apps/figcraft-server/src/lib.rs +++ b/apps/figcraft-server/src/lib.rs @@ -86,6 +86,8 @@ pub fn start(cfg: &Config) -> Result { Ok(Running { hosting }) } +#[cfg(test)] +mod admin_budget_tests; #[cfg(test)] mod background_tests; #[cfg(test)] diff --git a/apps/figcraft-server/src/public_tests.rs b/apps/figcraft-server/src/public_tests.rs index 37cbabe1..b63f0993 100644 --- a/apps/figcraft-server/src/public_tests.rs +++ b/apps/figcraft-server/src/public_tests.rs @@ -6,10 +6,8 @@ use std::io::{Read, Write}; use std::net::{IpAddr, SocketAddr, TcpStream}; use std::time::{Duration, Instant}; -use figcraft_collab::Replica; use figcraft_collab::message::{self, Admin, AdminReply, ClientMsg, OpenHosting, PROTOCOL, ServerMsg}; use figcraft_collab_net::{Endpoint, Invite, NetLimits, manage}; -use figcraft_doc::{Document, NodeKind}; use serde_json::{Value, json}; use tungstenite::client::IntoClientRequest; use tungstenite::{Bytes, Message}; @@ -18,24 +16,12 @@ use crate::abuse::{Abuse, AbuseConfig, Action, MAX_ADDRESSES}; use crate::cli::{self, Cli, Command, Sort}; use crate::config::Config; use crate::store::{KEY_ONLY, Store}; -use crate::test_util::{Raw, TempDir, config, join, key, live, pump, session}; +use crate::test_util::{Raw, TempDir, config, ip, join, key, live, pump, session, snapshot}; fn open(dir: &std::path::Path) -> Config { Config { open_publish: true, operator_contact: "abuse@example.com".into(), terms_url: "https://example.com/terms".into(), ..config(dir) } } -fn snapshot(name: &str) -> Vec { - let mut d = Document::new(name); - let page = d.first_page().unwrap(); - d.create(page, NodeKind::Rectangle, "Card", 0.0, 0.0, 100.0, 60.0).unwrap(); - d.repair(); - Replica::new(d).snapshot().unwrap() -} - -fn ip(s: &str) -> IpAddr { - s.parse().unwrap() -} - fn ex(s: &mut figcraft_engine::Session, cmd: &str, p: Value) -> Value { s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) } diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index 90fce92b..2108bfb8 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -30,7 +30,7 @@ //! hashes and compared in constant time. Whatever is damaged at startup is skipped and reported, //! never deleted. -use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque}; use std::fs::{self, File, OpenOptions, TryLockError}; use std::io::{Cursor, Write}; use std::net::IpAddr; @@ -88,6 +88,9 @@ const MAX_PROBLEMS: usize = 200; /// Why a download is turned away (an address's share, or everyone's, is used up for now). pub const BUSY_DOWNLOADS: &str = "too many downloads from your address or from everyone right now: try again in a minute"; +/// Why a request that changes the server's files is turned away (the address's share is used up). +pub const BUSY_ADMIN: &str = "too many changes to this server's files from your address: wait a minute and try again"; + /// Why publishing without the key is refused on a server that isn't open to the public. pub const KEY_ONLY: &str = "this server takes files only from people who have its key: add ?key=… to the address (its operator has the key)"; @@ -270,6 +273,9 @@ pub struct Store { // shortcut: in memory only, so after a restart such a code reads as unknown; keep them in the // data directory if people ask why their code "never existed". gone_codes: VecDeque, + /// The files whose links hold each join code, by the code's hash ([`Sealed::hash`]): a typed + /// code is looked up here instead of against every link. + codes: HashMap>, /// When idle files are looked for next (seconds since 1970). next_sweep: u64, /// Compactions so far (tests count them). @@ -396,6 +402,7 @@ impl Store { blocked: BTreeSet::new(), reports: Vec::new(), gone_codes: VecDeque::new(), + codes: HashMap::new(), next_sweep: 0, compactions: 0, cut_short: Vec::new(), @@ -448,6 +455,9 @@ impl Store { } match self.check(&path) { Ok(entry) => { + for c in entry.links.iter().filter_map(|l| l.code.as_ref()) { + self.codes.entry(c.hash.clone()).or_default().insert(id.clone()); + } self.files.insert(id, entry); } Err(why) => self.note(format!("skipped file {id}: {why}")), diff --git a/apps/figcraft-server/src/store/backend.rs b/apps/figcraft-server/src/store/backend.rs index 95fd8948..fc36a337 100644 --- a/apps/figcraft-server/src/store/backend.rs +++ b/apps/figcraft-server/src/store/backend.rs @@ -1,9 +1,18 @@ use super::journal::flush_log; use super::{ - Access, Action, Admin, AdminReply, Admission, BUSY_DOWNLOADS, Backend, ClientMsg, Closing, Handled, HostInfo, Instant, IpAddr, LibraryReply, - LibraryReq, OpenFile, PluginReply, PluginReq, Server, Store, disk, now, same_secret, + Access, Action, Admin, AdminReply, Admission, BUSY_ADMIN, BUSY_DOWNLOADS, Backend, ClientMsg, Closing, Handled, HostInfo, Instant, IpAddr, + LibraryReply, LibraryReq, OpenFile, PluginReply, PluginReq, Server, Store, disk, now, same_secret, }; +/// Whether `req` changes the server's files (a write, a removal or a join code's stretching): +/// such requests count against the address's budget ([`Action::Admin`]); looking doesn't. +fn changes_files(req: &Admin) -> bool { + !matches!( + req, + Admin::Files | Admin::Links { .. } | Admin::Listing | Admin::Reports | Admin::Blocked | Admin::Owned { .. } | Admin::RemovePeer { .. } + ) +} + impl Backend for Store { fn admit(&mut self, file: &str, token: &str) -> Result { let entry = self.entry(file)?; @@ -156,15 +165,19 @@ impl Backend for Store { if owns != Some(file.as_str()) { return failed("an owner token manages only its own file"); } - self.admin(req, open) + self.admin(ip, req, open) } fn report(&mut self, file: &str, link: Option<&str>, ip: IpAddr, reason: &str) -> Result<(), String> { self.keep_report(file, link, ip, reason) } - fn admin(&mut self, req: Admin, open: &dyn Fn(&str) -> Option) -> Handled { + fn admin(&mut self, ip: IpAddr, req: Admin, open: &dyn Fn(&str) -> Option) -> Handled { let failed = |reason: String| Handled::from(AdminReply::Failed { reason }); + if changes_files(&req) && !self.abuse.allow(ip, Action::Admin, Instant::now()) { + log::info!("event=refused ip={ip} reason=admin_per_address"); + return failed(BUSY_ADMIN.into()); + } match req { Admin::Publish { name, snapshot } => match self.publish(&name, &snapshot) { Ok((file, edit, view)) => AdminReply::Published { file, edit, view }.into(), diff --git a/apps/figcraft-server/src/store/links.rs b/apps/figcraft-server/src/store/links.rs index 6c279219..bbdcde7b 100644 --- a/apps/figcraft-server/src/store/links.rs +++ b/apps/figcraft-server/src/store/links.rs @@ -44,18 +44,30 @@ impl Store { found } - /// These join codes no longer open anything: typing one says so. - fn forget_codes(&mut self, codes: impl IntoIterator) { - self.gone_codes.extend(codes.into_iter().map(|c| c.hash)); + /// These join codes of `file` no longer open anything: typing one says so. A code is dropped + /// from the index once none of `file`'s links holds it. + fn forget_codes(&mut self, file: &str, codes: impl IntoIterator) { + for c in codes { + let held = self.files.get(file).is_some_and(|e| e.links.iter().any(|l| l.code.as_ref().is_some_and(|k| k.hash == c.hash))); + if !held && let Some(files) = self.codes.get_mut(&c.hash) { + files.remove(file); + if files.is_empty() { + self.codes.remove(&c.hash); + } + } + self.gone_codes.push_back(c.hash); + } let extra = self.gone_codes.len().saturating_sub(MAX_GONE_CODES); self.gone_codes.drain(..extra); } /// Where the link with the join code `key` is: its file, its place among the file's links - /// and its token (every code is compared, so timing doesn't tell which one matched). + /// and its token. Only the files the index names for the code's hash are looked at; their + /// links are each compared, in constant time. fn find_code(&self, key: &codes::Key) -> Option<(String, usize, String)> { let mut found = None; - for (id, e) in &self.files { + for id in self.codes.get(key.hash())? { + let Some(e) = self.files.get(id) else { continue }; for (i, l) in e.links.iter().enumerate() { if let Some(token) = l.code.as_ref().and_then(|c| key.open(c)) { found = Some((id.clone(), i, token)); @@ -78,6 +90,7 @@ impl Store { } }; let sealed = key.seal(token)?; + let found_by = sealed.hash.clone(); let path = self.dir(file).join(LINKS); let entry = self.entry(file)?; let link = entry.links.get_mut(at).ok_or("that file has no such link")?; @@ -89,7 +102,8 @@ impl Store { } return Err(format!("couldn't keep the join code: {e}")); } - self.forget_codes(before); + self.codes.entry(found_by).or_default().insert(file.to_string()); + self.forget_codes(file, before); log::info!("event=code file={file} link={id}"); Ok(code) } @@ -137,7 +151,7 @@ impl Store { return Err(format!("couldn't revoke the link: {e}")); } log::info!("revoked link {link} of file {file}"); - self.forget_codes(gone.code); + self.forget_codes(file, gone.code); Ok(()) } @@ -146,7 +160,7 @@ impl Store { pub fn remove(&mut self, file: &str) -> Result<(), String> { // Closed first: an open log would keep the folder from being deleted on Windows. let entry = self.files.remove(file).ok_or("this file isn't on the server (it may have been removed)")?; - self.forget_codes(entry.links.iter().filter_map(|l| l.code.clone())); + self.forget_codes(file, entry.links.iter().filter_map(|l| l.code.clone())); drop(entry); let dir = self.dir(file); fs::remove_dir_all(&dir) diff --git a/apps/figcraft-server/src/test_util.rs b/apps/figcraft-server/src/test_util.rs index 18fe0d86..da5b54a8 100644 --- a/apps/figcraft-server/src/test_util.rs +++ b/apps/figcraft-server/src/test_util.rs @@ -170,3 +170,17 @@ impl Raw { } } } + +/// A one-page file with one rectangle, named `name`, as a snapshot to publish. +pub fn snapshot(name: &str) -> Vec { + let mut d = figcraft_doc::Document::new(name); + let page = d.first_page().unwrap(); + d.create(page, figcraft_doc::NodeKind::Rectangle, "Card", 0.0, 0.0, 100.0, 60.0).unwrap(); + d.repair(); + figcraft_collab::Replica::new(d).snapshot().unwrap() +} + +/// An address from text. +pub fn ip(s: &str) -> std::net::IpAddr { + s.parse().unwrap() +} diff --git a/crates/collab-net/src/hub.rs b/crates/collab-net/src/hub.rs index 363ecae0..7b548fb0 100644 --- a/crates/collab-net/src/hub.rs +++ b/crates/collab-net/src/hub.rs @@ -305,7 +305,7 @@ impl Hub { let reply = self.removed_reply(&file, client); self.answer(conn, &ServerMsg::Admin(Box::new(reply))); } - ClientMsg::Admin(req) => self.admin(conn, req), + ClientMsg::Admin(req) => self.admin(conn, ip, req), ClientMsg::Ping => self.answer(conn, &ServerMsg::Pong), _ => self.kick(&[conn], "only requests to manage files are spoken on this connection"), }, @@ -538,10 +538,10 @@ impl Hub { } } - fn admin(&mut self, conn: u64, req: Admin) { + fn admin(&mut self, conn: u64, ip: IpAddr, req: Admin) { let rooms = &self.rooms; let open = |file: &str| rooms.get(file).map(Room::info); - let handled = self.backend.admin(req, &open); + let handled = self.backend.admin(ip, req, &open); self.handled(conn, handled); } diff --git a/crates/collab-net/src/hub/backend.rs b/crates/collab-net/src/hub/backend.rs index e594b0f1..929ab6ae 100644 --- a/crates/collab-net/src/hub/backend.rs +++ b/crates/collab-net/src/hub/backend.rs @@ -76,8 +76,9 @@ pub trait Backend: Send + 'static { Err("this session is hosted by an app: it has no files to manage".into()) } - /// A request on a managing connection; `open` tells about a file that is open. - fn admin(&mut self, _req: Admin, _open: &dyn Fn(&str) -> Option) -> Handled { + /// A request on a managing connection from `ip` ([`ip_key`]); `open` tells about a file that + /// is open. + fn admin(&mut self, _ip: IpAddr, _req: Admin, _open: &dyn Fn(&str) -> Option) -> Handled { AdminReply::Failed { reason: "nothing to manage here".into() }.into() } diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index 904b33b9..91ac86f0 100644 --- a/docs/collaboration-parity.md +++ b/docs/collaboration-parity.md @@ -1,6 +1,6 @@ # Collaboration parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); 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); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (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); comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); 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); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model, @@ -28,6 +28,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M9, #1075: `figcraft-server` gives requests that change its files (join codes, links, renames, removals, blocks, publishing with the key) a budget of 60 a minute per address and block (`Action::Admin`), on every server, with the server key and with an owner token alike; listing and looking aren't counted. Before, a managing connection with the key had no limit at all and an owner token only the message rate, so one address could ask for hundreds of 10,000-round code stretches and `links.json` writes a second (100 renames in a row: 100 served before, 60 now, then "too many changes to this server's files from your address…"; another address is unaffected). A typed or new join code is found through an index of code hashes to the files holding them, instead of opening every link's code on the server; results are the same, a copied file folder holding the same code included. The protocol is unchanged. Tests: `admin_budget_tests`. Numbers unchanged | | 2026-10-29 | minor | M9, #1415: `comment.list` (CLI, MCP `run_command`, the `figcraft://comments` resource) gives every message, the first and each reply, its `mentions`: `{name, known, you}` for each name it @mentions, by the rule the thread view highlights them with (`figcraft_doc::mentions`, which the highlighting now shares). A name resolves against the file's people and you, as the file spells it. An unknown name is reported with `known: false`. `a@b.com` mentions nobody. Filtering threads by a mentioned name and listing mentioned names in `comment.people` stay open. Numbers unchanged | | 2026-10-29 | minor | Bounded replay log (#1069, from a security audit): the batches a session keeps for apps that reconnect were bounded only by count (10,000 batches, 1,000,000 changes) on a desktop host and a private `figcraft-server`, so a long busy session could hold any number of bytes. `Limits::max_log_bytes` now defaults to 64 MiB, and `figcraft-server` takes it from a new `max_log_mb` setting (default 64; open to the public, also at most a quarter of `max_file_mb`, as before). An app further behind than the trimmed log catches up with the whole file, as it already did past the count limits. Nine 8 MiB renames with default limits kept all 75.5 MB before; now 58.7 MB, and a client that missed them gets a snapshot. The protocol is unchanged. | | 2026-10-29 | minor | Sibling placement in live sessions (#1065, from a security audit): every replica (the server's and each app's) placed a created, moved or reordered layer by counting each sibling that sorts before it, so a batch of many layers under one parent took quadratic time, on the server's single hub thread too. It now binary-searches the siblings' `(key, id)` order on each side of the layer's old place. The order is unchanged, ties and fractional positions included: a test replays 5,000 creations and 2,500 moves against the counting rule. 20,000 creations and 10,000 moves under two parents took 20 s in a debug build; they now take 0.3 s. | diff --git a/docs/roadmap.md b/docs/roadmap.md index 2e500c03..1082ae74 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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-10-29 · **Change:** minor (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); 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M9, #1075: `figcraft-server` gives requests that change its files a budget of 60 a minute per address, with the server key and owner tokens alike, and finds join codes through an index of their hashes. Tests: `admin_budget_tests` | | 2026-10-29 | minor | M8, #1080: SVG import draws a pattern's tile once per pattern and scale and reuses it for every other layer it fills, and the layers inside a tile count in the import's 100,000-layer budget instead of each tile starting a fresh one. Tests: `tile_tests` (svg) | | 2026-10-29 | patch | M4, #1478: a letter and its combining marks are shaped with one font that has them all. Text typed decomposed (NFD, `E` + U+0302 + U+0309 for Ể) in a font with the letters but not the marks drew missing-glyph boxes beside and above the letter, while the precomposed form fell back to a font that has it; each grapheme cluster now takes the first face (the style's own, then the fallbacks) that covers every char in it (joiners and variation selectors need no glyph), else the letter's own face as before. Fonts that cover Vietnamese were already right: mark positioning (GPOS `mark`/`mkmk`) stacks the marks over the letter on the canvas, in PNG, SVG and PDF. Tests in `crates/text/src/vietnamese_tests.rs`. Numbers unchanged | | 2026-10-29 | minor | M9, #1415: `comment.list` (CLI, MCP `run_command`, the `figcraft://comments` resource) gives every message, the first and each reply, its `mentions`: `{name, known, you}` for each name it @mentions, by the rule the thread view highlights them with (`figcraft_doc::mentions`, which the highlighting now shares). A name resolves against the file's people and you, as the file spells it. An unknown name is reported with `known: false`. `a@b.com` mentions nobody. Filtering threads by a mentioned name and listing mentioned names in `comment.people` stay open. Numbers unchanged | diff --git a/docs/server.md b/docs/server.md index a88d3a3c..0dcd26c9 100644 --- a/docs/server.md +++ b/docs/server.md @@ -498,7 +498,7 @@ are larger. | Bytes being read | Messages not yet whole: the largest message and 1 MiB per block, 192 MiB for everyone; past everyone's budget the readers holding the most are cut off until it fits (their bytes count as free at once), and a reader is refused only when it is the biggest | | Bytes waiting for the server | 128 MiB of read messages not handled yet: past it a connection is dropped as busy | | Connections | `max_connections` (512) in all (*always*); `max_connections_per_ip` (32) per address, ×2 per IPv6 /56 (at most a sixteenth of all), ×4 per /48 (at most an eighth of all), joins included, so no block can take every connection; one-shot requests (asking about hosting, libraries, browsing plugins) can't take the last eighth, which stays for people joining files, and are dropped after 20 idle seconds | -| Rates | Per address and block: `connects_per_minute`, `publishes_per_hour` (and 32 addresses' worth for everyone), `messages_per_second` (cursors, selections, cursor chat and pings have their own budget beside it, twice that, so an office moving its cursors together doesn't trip it); reports, ten an hour (*always*); downloads of libraries and plugins, 1 MiB a second with bursts of 16 MiB per address (*always*), checked before anything is read, within 32 MiB a second (512 MiB at once) for libraries and for plugins, everyone together | +| Rates | Per address and block: `connects_per_minute`, `publishes_per_hour` (and 32 addresses' worth for everyone), `messages_per_second` (cursors, selections, cursor chat and pings have their own budget beside it, twice that, so an office moving its cursors together doesn't trip it); reports, ten an hour (*always*); requests that change files (join codes, links, renames, removals, blocks, publishing with the key), sixty a minute, with the server key or an owner token too ("too many changes to this server's files from your address…"; listing and looking aren't counted) (*always*); downloads of libraries and plugins, 1 MiB a second with bursts of 16 MiB per address (*always*), checked before anything is read, within 32 MiB a second (512 MiB at once) for libraries and for plugins, everyone together | | Team libraries | Only files published with the key hold one on a public server; an archive is at most `max_file_mb` and everything it unpacks to counts against the same cap (*always*), so a zip bomb is refused before it expands | | Usage reports | Kept in memory, written at most once a minute per library and when the server stops; at most 1,000 components per file and 200 files per library (*always*) | | Disk writes | A batch is confirmed only once it is on disk; when its log can't be written, nobody hears it was accepted: the file closes, everyone in it reconnects and their apps send again what wasn't confirmed. Below 1 GiB (or two of the largest files) free, changes and new files are refused with "the server's disk is nearly full" instead (*always*, where the free space can be told: Unix). The server keeps the last 200 problems for the operator | From af2df345bc5db3a8e07f8dcc23711f9eb4ed5936 Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 30 Oct 2026 00:51:46 +0700 Subject: [PATCH 50/63] M9: the Join dialog shows a join until its file is ready, with Cancel and Try again (#1381) (#1553) --- ROADMAP.md | 3 +- crates/engine/src/sharing/live/join.rs | 12 ++- crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/src/home.rs | 9 +- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/live.rs | 109 ++++++++++++++++++++--- crates/ui-egui/src/live_joining_tests.rs | 99 ++++++++++++++++++++ crates/ui-egui/src/live_share_tests.rs | 11 +-- crates/ui-egui/src/live_tests.rs | 5 ++ docs/collaboration-parity.md | 5 +- docs/mcp.md | 3 +- docs/roadmap.md | 3 +- 12 files changed, 232 insertions(+), 30 deletions(-) create mode 100644 crates/ui-egui/src/live_joining_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index d05353d3..e8e9389d 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-29 · **Change:** minor (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); 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-10-29 · **Change:** minor (the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); 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); 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 @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M9, #1381: joining a live session shows its progress. From the click on Join (the Share popover, the home screen or the Join dialog, which an invite link opens) the Join dialog says "Joining live session…" with a spinner and Cancel until the shared file is in its own tab and ready, then the file shows; while it is on its way the join fields don't start a second one. A join that fails or times out says why in the dialog, with Try again (a new join with the same code or link) and Cancel; Escape and the close button cancel too. `collab.leave` takes `session` to end a session by its id, whichever tab is in front. Tests: `live_joining_tests`. Numbers unchanged | | 2026-10-29 | minor | M9, #1075: `figcraft-server` gives requests that change its files (join codes, links, renames, removals, blocks, publishing with the key) a budget of 60 a minute per address, with the server key and owner tokens alike (100 renames in a row from one address: 100 served before, 60 now; another address is unaffected), and finds join codes through an index of their hashes instead of opening every link's code. Same results, same protocol. Tests: `admin_budget_tests`. Numbers unchanged | | 2026-10-29 | minor | M8, #1080: SVG import draws a pattern's tile once per pattern and scale and reuses it for every other layer it fills (1,000 rectangles filled with one pattern: 1,000 tile drawings before, 1 now, same image fill), and the layers inside a tile count in the import's 100,000-layer budget instead of each tile starting a fresh one, so patterns nested in patterns stop at the budget. Tests: `tile_tests` (svg). Numbers unchanged | | 2026-10-29 | patch | M4, #1478: a letter and its combining marks are shaped with one font that has them all. Text typed decomposed (NFD, `E` + U+0302 + U+0309 for Ể) in a font with the letters but not the marks drew missing-glyph boxes beside and above the letter, while the precomposed form fell back to a font that has it; each grapheme cluster now takes the first face (the style's own, then the fallbacks) that covers every char in it (joiners and variation selectors need no glyph), else the letter's own face as before. Fonts that cover Vietnamese were already right: mark positioning (GPOS `mark`/`mkmk`) stacks the marks over the letter on the canvas, in PNG, SVG and PDF. Tests in `crates/text/src/vietnamese_tests.rs`. Numbers unchanged | diff --git a/crates/engine/src/sharing/live/join.rs b/crates/engine/src/sharing/live/join.rs index 1742382a..c9e54722 100644 --- a/crates/engine/src/sharing/live/join.rs +++ b/crates/engine/src/sharing/live/join.rs @@ -34,8 +34,14 @@ pub(crate) fn start_join( Ok(id) } -fn leave(s: &mut Session, _: &Value) -> Result { - let ended = s.live.shown(s.tab()).and_then(|id| s.live.end(id)); +fn leave(s: &mut Session, p: &Value) -> Result { + // A join on its way is called off by its id, whichever tab is in front (the Join dialog's + // Cancel); one that already ended is no error. + let id = match get(p, "session") { + Some(v) => Some(v.as_u64().ok_or_else(|| bad("collab.leave", "`session` must be a session id (`collab.peers` lists them)"))?), + None => s.live.shown(s.tab()), + }; + let ended = id.and_then(|id| s.live.end(id)); if ended.as_ref().is_some_and(|l| l.syncing() && l.role() == Some(Role::Guest)) { s.message = Some("You left the session; your copy stays open here".into()); } @@ -189,7 +195,7 @@ pub fn specs() -> Vec { spec!( "collab.leave", "Leave live session", - "{} (the open file's session; the synced file stays open as a local copy) → {ended}", + "{session? (a session's id from `collab.peers`'s `sessions`, e.g. a join still on its way; default: the open file's session)} → {ended}: the synced file stays open as a local copy", false, leave ), diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 3450db8e..14167386 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -727,6 +727,7 @@ name = "English" "Join with a code" = "Join with a code" "Joined" = "Joined" "Joined {file}" = "Joined {file}" +"Joining live session…" = "Joining live session…" "Joining…" = "Joining…" "Join|stroke" = "Join" "Jul" = "Jul" diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 05c8fcda..4d891f87 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -343,16 +343,11 @@ fn recents(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { if open { app.open_dialog(); } - // Join a file someone shared: the join field, and what the join is doing. + // Join a file someone shared (the Join dialog shows what the join is doing). subheading(ui, t, tr("Join with a code")); ui.scope(|ui| { ui.set_max_width(540.0); - if let Some(session) = crate::live::join_row(app, ui, 260.0) { - app.ui.live.home_join = Some(session); - } - if let Some(session) = app.ui.live.home_join { - crate::live::join_status(app, ui, t, session); - } + crate::live::join_row(app, ui, 260.0); }); opened_join(app); if !app.session.prefs.getting_started_dismissed { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0731c980..1d1a8d4b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -123,6 +123,8 @@ mod live_docs_tests; #[cfg(test)] mod live_join_tests; #[cfg(test)] +mod live_joining_tests; +#[cfg(test)] mod live_server_tests; #[cfg(test)] mod live_share_tests; diff --git a/crates/ui-egui/src/live.rs b/crates/ui-egui/src/live.rs index e16d2cfa..28b50f80 100644 --- a/crates/ui-egui/src/live.rs +++ b/crates/ui-egui/src/live.rs @@ -108,6 +108,9 @@ pub struct LiveUi { /// The join started from the home screen: what it is doing shows under the field, and its /// file's tab shows when it opens. pub home_join: Option, + /// A join started from a join field, with what was typed for it: the Join dialog shows it + /// from the click until its file is ready, or why it failed, to try again. + pub joining: Option<(SessionId, String)>, /// Remove from server… was clicked: its confirmation shows. pub confirm_remove: bool, /// What the server for online sharing said about its hosting (`collab.serverInfo`), and the @@ -685,17 +688,96 @@ pub fn join_field(app: &mut FigApp, ui: &mut Ui, typed: &mut String, id: egui::I if focus { resp.request_focus(); } - let filled = !typed.trim().is_empty(); + // One join at a time: the field waits while one is on its way. + let filled = !typed.trim().is_empty() && !join_pending(app); let enter = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); let go = widgets::button(ui, tr("Join"), true, filled).clicked() || enter; - let joined = if go && filled { app.run("collab.join", json!({"code": typed.trim()})) } else { None }; - let session = joined.and_then(|j| j["session"].as_u64()); + let session = if go && filled { start_join(app, typed.trim()) } else { None }; if session.is_some() { typed.clear(); } session } +/// Join with `typed` (a code or an invite link); the Join dialog follows the join. Its session, if +/// it started. +fn start_join(app: &mut FigApp, typed: &str) -> Option { + let session = app.run("collab.join", json!({"code": typed})).and_then(|j| j["session"].as_u64())?; + app.ui.live.joining = Some((session, typed.to_string())); + Some(session) +} + +/// A join started from a join field is on its way (not ready and not failed). +fn join_pending(app: &FigApp) -> bool { + app.ui.live.joining.as_ref().and_then(|(id, _)| app.session.live.get(*id)).is_some_and(|l| !l.syncing() && l.status.active()) +} + +/// Call off the join the Join dialog follows (it may have failed: it is forgotten). +fn cancel_join(app: &mut FigApp) { + if let Some((id, _)) = app.ui.live.joining.take() { + app.run("collab.leave", json!({"session": id})); + } +} + +/// What the join the Join dialog follows is doing: `None` when there is none to show (its file +/// is ready, and then shows, or it ended elsewhere); else why it failed, or `None` inside while +/// it is on its way. +fn join_progress(app: &mut FigApp) -> Option> { + let (id, _) = app.ui.live.joining.as_ref()?; + let Some(live) = app.session.live.get(*id) else { + app.ui.live.joining = None; + return None; + }; + if live.syncing() { + app.ui.live.joining = None; + app.ui.home = false; + return None; + } + Some(match &live.status { + Status::Failed { reason } => Some(failure_text(reason).into_owned()), + _ => None, + }) +} + +/// The join started from a join field, from the click until its file is ready: joining (Cancel +/// calls it off), or why it failed (Try again joins with the same code). Whether it showed. +fn joining_dialog(app: &mut FigApp, ctx: &egui::Context) -> bool { + let Some(failed) = join_progress(app) else { return false }; + let t = Tokens::get(ctx); + let (mut open, mut cancel, mut retry) = (true, false, false); + crate::dialogs::window(tr("Join live session"), ctx).id(egui::Id::unique("Join live session")).open(&mut open).show(ctx, |ui| { + ui.set_width(380.0); + match &failed { + None => { + ui.horizontal(|ui| { + ui.add(egui::Spinner::new().size(14.0)); + widgets::label(ui, tr("Joining live session…"), t.text); + }); + } + Some(reason) => widgets::label(ui, reason, t.danger), + } + ui.horizontal(|ui| { + retry = failed.is_some() && widgets::button(ui, tr("Try again"), true, true).clicked(); + cancel = widgets::button(ui, tr("Cancel"), false, true).clicked(); + }); + }); + if retry { + retry_join(app); + } else if cancel || !open { + cancel_join(app); + } + true +} + +/// Join again with what the failed join was started with; the failed one goes once the new one +/// is on its way. +fn retry_join(app: &mut FigApp) { + let Some((old, typed)) = app.ui.live.joining.clone() else { return }; + if start_join(app, &typed).is_some() { + app.run("collab.leave", json!({"session": old})); + } +} + /// What join `session` is doing, under the field it was started from (the home screen): on its /// way, or why it failed. pub fn join_status(app: &FigApp, ui: &mut Ui, t: &Tokens, session: SessionId) { @@ -1089,20 +1171,27 @@ pub fn open_invite(app: &mut FigApp, link: &str) { open_join(app, link.trim()); } -/// Escape closes the Join or Publish dialog (before the canvas's shortcuts see it: it doesn't -/// deselect). +/// Escape closes the Join or Publish dialog, and calls off the join the Join dialog follows +/// (before the canvas's shortcuts see it: it doesn't deselect). pub fn dialog_keys(app: &mut FigApp, ctx: &egui::Context) { - let live = &mut app.ui.live; - if (live.join.is_some() || live.publish.is_some()) && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { - live.join = None; - live.publish = None; + let live = &app.ui.live; + if (live.join.is_some() || live.publish.is_some() || live.joining.is_some()) + && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) + { + app.ui.live.join = None; + app.ui.live.publish = None; + cancel_join(app); } } -/// File ▸ Join live session: type a join code or paste an invite link. +/// File ▸ Join live session: type a join code or paste an invite link; then the join, until its +/// file is ready. pub fn join_dialog(app: &mut FigApp, ctx: &egui::Context) { // The editor takes Escape before its shortcuts; the home screen has none. dialog_keys(app, ctx); + if joining_dialog(app, ctx) { + return; + } let Some(mut link) = app.ui.live.join.take() else { return }; let t = Tokens::get(ctx); let mut open = true; diff --git a/crates/ui-egui/src/live_joining_tests.rs b/crates/ui-egui/src/live_joining_tests.rs new file mode 100644 index 00000000..a5d46b8e --- /dev/null +++ b/crates/ui-egui/src/live_joining_tests.rs @@ -0,0 +1,99 @@ +//! The Join dialog follows a join from the click until its file is ready: joining (with Cancel), +//! then the file in a tab of its own; or why it failed, with Try again (#1381). + +use egui::{Event, Key}; +use figcraft_engine::live::{Event as LiveEvent, Status}; +use serde_json::json; + +use crate::live_tests::{Harness, has_text, text_center}; + +const LINK: &str = "figcraft://join?host=127.0.0.1:47900&file=f&token=t"; + +/// Join from the Join dialog with the invite link: the session it started. +fn join(h: &mut Harness) -> u64 { + crate::live::open_invite(&mut h.app, LINK); + // A new window measures itself first. + let shapes = h.frames(3); + h.click(text_center(&shapes, "Join")); + let shapes = h.frames(1); + assert!(has_text(&shapes, "Joining live session…") && has_text(&shapes, "Cancel"), "the dialog shows the join at once"); + let (id, typed) = h.app.ui.live.joining.clone().unwrap(); + assert_eq!(typed, LINK); + id +} + +fn joining_shown(h: &mut Harness) -> bool { + has_text(&h.frames(2), "Joining live session…") +} + +#[test] +fn joining_shows_until_the_file_is_ready() { + let (mut h, _) = Harness::new(); + let id = join(&mut h); + // One join at a time: the home screen's field waits. + h.app.ui.home = true; + h.app.ui.live.code = LINK.into(); + let shapes = h.frames(2); + h.click(text_center(&shapes, "Join")); + assert_eq!(h.app.ui.live.joining.as_ref().map(|j| j.0), Some(id), "no second join"); + assert_eq!(h.app.session.live.iter().count(), 1); + // Welcomed, but the file isn't here yet: still joining. + h.welcome(); + assert!(joining_shown(&mut h)); + h.sync(); + assert!(!joining_shown(&mut h) && h.app.ui.live.joining.is_none(), "it goes once the file is ready"); + assert!(!h.app.ui.home, "and the joined file shows"); + assert_eq!(h.app.session.docs.len(), 2); +} + +#[test] +fn a_failed_join_says_why_and_tries_again() { + let (mut h, _) = Harness::new(); + let first = join(&mut h); + h.send(vec![LiveEvent::Status(Status::Failed { reason: "Couldn't reach 127.0.0.1:47900".into() })]); + let shapes = h.frames(2); + assert!(has_text(&shapes, "Couldn't reach 127.0.0.1:47900") && has_text(&shapes, "Try again")); + assert!(!has_text(&shapes, "Joining live session…")); + h.click(text_center(&shapes, "Try again")); + let second = h.app.ui.live.joining.as_ref().map(|j| j.0).unwrap(); + assert_ne!(second, first, "a new join with the same link"); + assert!(h.app.session.live.get(first).is_none(), "the failed one is gone"); + assert_eq!(h.app.session.live.get(second).map(|l| l.status.clone()), Some(Status::Connecting)); + assert!(joining_shown(&mut h)); + h.welcome(); + h.sync(); + assert!(h.app.ui.live.joining.is_none() && h.app.session.live.get(second).is_some_and(|l| l.syncing())); +} + +#[test] +fn cancel_and_escape_call_the_join_off() { + let (mut h, _) = Harness::new(); + let id = join(&mut h); + let shapes = h.frames(1); + h.click(text_center(&shapes, "Cancel")); + assert!(h.app.ui.live.joining.is_none() && h.app.session.live.get(id).is_none(), "the join ended"); + assert_eq!(h.app.session.live().status, Status::Off); + assert!(!joining_shown(&mut h)); + // Escape is Cancel, and so is a failed join's Cancel (it is forgotten). + let id = join(&mut h); + for pressed in [true, false] { + h.frame(vec![Event::Key { key: Key::Escape, physical_key: None, pressed, repeat: false, modifiers: Default::default() }]); + } + assert!(h.app.ui.live.joining.is_none() && h.app.session.live.get(id).is_none()); + let id = join(&mut h); + h.send(vec![LiveEvent::Status(Status::Failed { reason: "No session with that code".into() })]); + let shapes = h.frames(2); + h.click(text_center(&shapes, "Cancel")); + assert!(h.app.ui.live.joining.is_none() && h.app.session.live.get(id).is_none()); + assert!(!has_text(&h.frames(2), "Try again")); +} + +#[test] +fn leave_takes_a_session_id() { + let (mut h, _) = Harness::new(); + let id = h.app.session.execute("collab.join", &json!({"link": LINK})).unwrap()["session"].as_u64().unwrap(); + assert!(h.app.session.execute("collab.leave", &json!({"session": "one"})).is_err()); + assert_eq!(h.app.session.execute("collab.leave", &json!({"session": id + 7})).unwrap()["ended"], false, "an unknown one is no error"); + assert_eq!(h.app.session.execute("collab.leave", &json!({"session": id})).unwrap()["ended"], true); + assert!(h.app.session.live.get(id).is_none()); +} diff --git a/crates/ui-egui/src/live_share_tests.rs b/crates/ui-egui/src/live_share_tests.rs index a2ac63c2..f2fbe11a 100644 --- a/crates/ui-egui/src/live_share_tests.rs +++ b/crates/ui-egui/src/live_share_tests.rs @@ -450,8 +450,8 @@ fn the_home_screen_joins_with_a_code_and_opens_the_file() { key(&mut h, Key::Enter); assert_eq!(script.lock().unwrap().joined, ["figcraft://join?server=wss://share.example/&code=K7M2QX9TD"]); let shapes = h.frames(2); - assert!(has_text(&shapes, "Joining…") && h.app.ui.home); - // It failed: the reason shows under the field, and the home screen stays. + assert!(has_text(&shapes, "Joining live session…") && h.app.ui.home, "the Join dialog shows the join"); + // It failed: the dialog says why, and the home screen stays. script.lock().unwrap().events.push(LiveEvent::Status(Status::Failed { reason: "That code has expired or its sharing was stopped".into() })); let shapes = h.frames(3); assert!(has_text(&shapes, "That code has expired or its sharing was stopped") && h.app.ui.home); @@ -467,8 +467,8 @@ fn the_home_screen_joins_with_a_code_and_opens_the_file() { assert_eq!(h.app.session.docs.len(), 2); } -/// Asserts the join dialog uses the same shared join field, focuses it, and closes once the -/// join starts. +/// Asserts the join dialog uses the same shared join field, focuses it, and shows the join once +/// it starts. #[test] fn the_join_dialog_is_the_same_field() { let (mut h, script) = harness(); @@ -480,7 +480,8 @@ fn the_join_dialog_is_the_same_field() { key(&mut h, Key::Enter); h.frames(1); assert_eq!(script.lock().unwrap().joined, ["figcraft://join?server=wss://share.example/&code=4FHJ8R2WN"]); - assert!(h.app.ui.live.join.is_none(), "it closes once the join started"); + assert!(h.app.ui.live.join.is_none(), "its field goes once the join started"); + assert!(has_text(&h.frames(1), "Joining live session…"), "and it shows the join"); } #[test] diff --git a/crates/ui-egui/src/live_tests.rs b/crates/ui-egui/src/live_tests.rs index 63c3c42a..cd8454af 100644 --- a/crates/ui-egui/src/live_tests.rs +++ b/crates/ui-egui/src/live_tests.rs @@ -114,6 +114,11 @@ impl Harness { /// Joined as Ada, with Hana (the host) already here. pub(crate) fn joined(&mut self) { self.app.session.execute("collab.join", &json!({"link": "figcraft://join?host=127.0.0.1:47900&file=f&token=t"})).unwrap(); + self.welcome(); + } + + /// The host's welcome for the join started last: you are Ada, with Hana (the host) here. + pub(crate) fn welcome(&mut self) { let info = SessionInfo { role: Role::Guest, me: 2, diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index 91ac86f0..1091548a 100644 --- a/docs/collaboration-parity.md +++ b/docs/collaboration-parity.md @@ -1,6 +1,6 @@ # Collaboration parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (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); comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); 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); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); 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); comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); 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); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model, @@ -17,7 +17,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). A refused batch's fixes take time linear in the batch: each deleted layer is restored once and each entry fix looked up once (#1063). Left: server archives beyond Linux | 25–55 | | Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors, selection outlines, avatars, follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 | | Cursor chat, audio, high-fives | yes | **partial, in live sessions**: cursor chat (`/` opens a bubble at your pointer; everyone sees it as you type, it fades a few seconds after the last change, Enter sends and Escape takes it back, 100 characters; `collab.cursorChat` for agents); sent as ephemeral presence the host checks and rate-limits, never stored. No audio, high-fives or emote reactions | 2–5 | -| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen; the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser)); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer: no accounts, teams or projects | 8–17 | +| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen, and the Join dialog then shows the join until its file is ready (Cancel), or why it failed (Try again, #1381); the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser)); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer: no accounts, teams or projects | 8–17 | | Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences (an AI agent attached over MCP comments as its own name, marked Agent); a behaviour suite from the documentation (pinning and following layers, threads, own edits and deletes, mentions, reactions, C and Shift+C, stepping, filters, saving, copy and paste); restoring a version keeps them. `comment.list` (CLI, MCP) gives each message its `mentions` (`{name, known, you}`, by the highlighting rule). In a live session threads sync and anyone in it can reply, react and resolve (no accounts: a name is who you are) | (shared once multiplayer lands) | | Version history: named versions, autosave checkpoints, view before restore, restore, duplicate | yes (cloud) | **done**, saved in the `.figcraft` file: restoring keeps the replaced design as an autosave first, a titled autosave becomes a named version, merging a branch records the file before and after. Versions are per person in a live session (a shared history needs accounts); no comparing two versions (Dev Mode compares a ready-for-dev layer with its state when marked) | — | | Branching and merging | yes (paid plans) | **done, also in live sessions**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived; entries for its creation and updates from main); fills, strokes, effects, layout grids, export settings, guides, interactions, annotations and Dev Mode resources merge item by item. In a live session a branch stays with its author (its ids in their own client space, far above the main file's) and merging it is one edit everyone gets; peers' edits merge against the branch's own base and conflicts resolve as offline; a view link refuses merging. Left: others in the session can't see or open your branches (the server would have to keep them); text runs conflict as a whole; a branch made offline that adds layers merges only outside a session | 1–2 | @@ -28,6 +28,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M9, #1381: joining a live session shows its progress. From the click on Join (the Share popover, the home screen or the Join dialog, which an invite link opens) the Join dialog says "Joining live session…" with a spinner and Cancel until the shared file is in its own tab and ready, then the file shows; while it is on its way the join fields don't start a second one. A join that fails or times out says why in the dialog, with Try again (a new join with the same code or link) and Cancel; Escape and the close button cancel too. `collab.leave` takes `session` to end a session by its id, whichever tab is in front. Tests: `live_joining_tests`. Numbers unchanged | | 2026-10-29 | minor | M9, #1075: `figcraft-server` gives requests that change its files (join codes, links, renames, removals, blocks, publishing with the key) a budget of 60 a minute per address and block (`Action::Admin`), on every server, with the server key and with an owner token alike; listing and looking aren't counted. Before, a managing connection with the key had no limit at all and an owner token only the message rate, so one address could ask for hundreds of 10,000-round code stretches and `links.json` writes a second (100 renames in a row: 100 served before, 60 now, then "too many changes to this server's files from your address…"; another address is unaffected). A typed or new join code is found through an index of code hashes to the files holding them, instead of opening every link's code on the server; results are the same, a copied file folder holding the same code included. The protocol is unchanged. Tests: `admin_budget_tests`. Numbers unchanged | | 2026-10-29 | minor | M9, #1415: `comment.list` (CLI, MCP `run_command`, the `figcraft://comments` resource) gives every message, the first and each reply, its `mentions`: `{name, known, you}` for each name it @mentions, by the rule the thread view highlights them with (`figcraft_doc::mentions`, which the highlighting now shares). A name resolves against the file's people and you, as the file spells it. An unknown name is reported with `known: false`. `a@b.com` mentions nobody. Filtering threads by a mentioned name and listing mentioned names in `comment.people` stay open. Numbers unchanged | | 2026-10-29 | minor | Bounded replay log (#1069, from a security audit): the batches a session keeps for apps that reconnect were bounded only by count (10,000 batches, 1,000,000 changes) on a desktop host and a private `figcraft-server`, so a long busy session could hold any number of bytes. `Limits::max_log_bytes` now defaults to 64 MiB, and `figcraft-server` takes it from a new `max_log_mb` setting (default 64; open to the public, also at most a quarter of `max_file_mb`, as before). An app further behind than the trimmed log catches up with the whole file, as it already did past the count limits. Nine 8 MiB renames with default limits kept all 75.5 MB before; now 58.7 MB, and a client that missed them gets a snapshot. The protocol is unchanged. | diff --git a/docs/mcp.md b/docs/mcp.md index e0d66b4c..3c0f8d5a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1377,7 +1377,8 @@ only: a host stops hosting it, a guest leaves it. session, a revoked link, an unreachable host, a `wss://` server whose certificate isn't trusted: see `FIGCRAFT_CA_FILE` in [`server.md`](server.md#behind-a-reverse-proxy-tls)). `collab.leave {}` leaves the active document's session (or ends a join started from it that is still on its way - or failed); the synced document stays open as your copy. + or failed), and `collab.leave {session}` the session with that id (from `sessions`), whichever + document is active; the synced document stays open as your copy. - `collab.publish {server, name?, key?}` puts the active document on a `figcraft-server` ([`server.md`](server.md)) and syncs it from there (as that document's session; others go on): `server` is the address `figcraft-server key` prints (`ws://host:47900/?key=…`; or give `key` separately), `name` the diff --git a/docs/roadmap.md b/docs/roadmap.md index 1082ae74..80aa065c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (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); 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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-10-29 · **Change:** minor (the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); 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); 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M9, #1381: joining a live session shows its progress. From the click on Join (the Share popover, the home screen or the Join dialog, which an invite link opens) the Join dialog says "Joining live session…" with a spinner and Cancel until the shared file is in its own tab and ready, then the file shows; while it is on its way the join fields don't start a second one. A join that fails or times out says why in the dialog, with Try again (a new join with the same code or link) and Cancel; Escape and the close button cancel too. `collab.leave` takes `session` to end a session by its id, whichever tab is in front. Tests: `live_joining_tests`. Numbers unchanged | | 2026-10-29 | minor | M9, #1075: `figcraft-server` gives requests that change its files a budget of 60 a minute per address, with the server key and owner tokens alike, and finds join codes through an index of their hashes. Tests: `admin_budget_tests` | | 2026-10-29 | minor | M8, #1080: SVG import draws a pattern's tile once per pattern and scale and reuses it for every other layer it fills, and the layers inside a tile count in the import's 100,000-layer budget instead of each tile starting a fresh one. Tests: `tile_tests` (svg) | | 2026-10-29 | patch | M4, #1478: a letter and its combining marks are shaped with one font that has them all. Text typed decomposed (NFD, `E` + U+0302 + U+0309 for Ể) in a font with the letters but not the marks drew missing-glyph boxes beside and above the letter, while the precomposed form fell back to a font that has it; each grapheme cluster now takes the first face (the style's own, then the fallbacks) that covers every char in it (joiners and variation selectors need no glyph), else the letter's own face as before. Fonts that cover Vietnamese were already right: mark positioning (GPOS `mark`/`mkmk`) stacks the marks over the letter on the canvas, in PNG, SVG and PDF. Tests in `crates/text/src/vietnamese_tests.rs`. Numbers unchanged | From e0abd55d69af753f1210b8d2fd26e001be6587f8 Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 30 Oct 2026 01:27:52 +0700 Subject: [PATCH 51/63] M9: one clear, persistent display name, renamed live in sessions (#1380) (#1554) * M9: one name for comments and live sessions, shown in the Join dialog, kept in the browser, renamed live (#1380) * M9: a reconnecting session's reset is its own helper (#1380) --- ROADMAP.md | 3 +- apps/figcraft-web/Cargo.toml | 4 +- apps/figcraft-web/src/kept.rs | 39 +++++++++++++++ apps/figcraft-web/src/main.rs | 3 ++ apps/figcraft-web/src/web.rs | 14 ++++++ crates/collab-net/src/lib.rs | 2 + crates/collab-net/src/rename_tests.rs | 34 +++++++++++++ crates/collab-net/src/session_tests.rs | 6 +-- crates/collab/src/lib.rs | 2 + crates/collab/src/message.rs | 25 ++++++++-- crates/collab/src/rename_tests.rs | 44 +++++++++++++++++ crates/collab/src/server.rs | 12 +++++ crates/engine/src/cmd/view.rs | 19 ++++--- crates/engine/src/sharing/live/events.rs | 62 +++++++++++++++++------ crates/engine/src/sharing/live/types.rs | 2 +- crates/ui-egui/locales/ar.toml | 3 +- crates/ui-egui/locales/bn.toml | 1 - crates/ui-egui/locales/cs.toml | 3 +- crates/ui-egui/locales/de.toml | 3 +- crates/ui-egui/locales/en.toml | 1 - crates/ui-egui/locales/es.toml | 3 +- crates/ui-egui/locales/fi.toml | 3 +- crates/ui-egui/locales/fr.toml | 3 +- crates/ui-egui/locales/id.toml | 3 +- crates/ui-egui/locales/it.toml | 3 +- crates/ui-egui/locales/ja.toml | 3 +- crates/ui-egui/locales/ka.toml | 3 +- crates/ui-egui/locales/ko.toml | 3 +- crates/ui-egui/locales/nl.toml | 3 +- crates/ui-egui/locales/pl.toml | 3 +- crates/ui-egui/locales/pt-BR.toml | 3 +- crates/ui-egui/locales/ru.toml | 3 +- crates/ui-egui/locales/sv.toml | 3 +- crates/ui-egui/locales/th.toml | 3 +- crates/ui-egui/locales/tr.toml | 3 +- crates/ui-egui/locales/vi.toml | 3 +- crates/ui-egui/locales/zh-Hans.toml | 3 +- crates/ui-egui/src/dialogs.rs | 6 +-- crates/ui-egui/src/display_name_tests.rs | 63 ++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/live.rs | 28 ++++++++--- docs/collab-protocol.md | 13 +++-- docs/collaboration-parity.md | 5 +- docs/roadmap.md | 3 +- 44 files changed, 361 insertions(+), 92 deletions(-) create mode 100644 apps/figcraft-web/src/kept.rs create mode 100644 crates/collab-net/src/rename_tests.rs create mode 100644 crates/collab/src/rename_tests.rs create mode 100644 crates/ui-egui/src/display_name_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 50fa01b0..116d247a 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-29 · **Change:** minor (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); 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); 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-10-29 · **Change:** minor (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); 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); 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 @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M9, #1380: your name is one setting, shown where you join. Preferences calls it "Your name" (it signs your comments and is what others see in live sessions). The Join dialog shows it, editable, beside the code, as the Share popover does, and the field is empty while it is the default. A new name is kept at once, and the browser build keeps it in the page's local storage, so a reload joins as you (the other preferences still start afresh there). Changing it while in live sessions renames you in each of them: a `name` signal, which the server cleans and answers with your `PeerJoined` to everyone, and newcomers' welcomes list the new name. A rename made while joining, or before a reconnect, goes out once the host reads signals. An older server ignores it, and clearing the name keeps the session's name until you join again. Tests: `collab/src/rename_tests.rs`, `collab-net/src/rename_tests.rs` (two apps over loopback), `ui-egui/src/display_name_tests.rs`, and `figcraft-web`'s `kept` round-trip. Numbers unchanged | | 2026-10-29 | minor | A marquee from anywhere in vector edit mode (#1508): a press on another layer used to leave edit mode at once, so a drag there moved that layer; it now draws a marquee that selects the points inside it. A click there still leaves edit mode and selects what it lands on. Numbers unchanged | | 2026-10-29 | minor | M9, #1381: joining a live session shows its progress. From the click on Join (the Share popover, the home screen or the Join dialog, which an invite link opens) the Join dialog says "Joining live session…" with a spinner and Cancel until the shared file is in its own tab and ready, then the file shows; while it is on its way the join fields don't start a second one. A join that fails or times out says why in the dialog, with Try again (a new join with the same code or link) and Cancel; Escape and the close button cancel too. `collab.leave` takes `session` to end a session by its id, whichever tab is in front. Tests: `live_joining_tests`. Numbers unchanged | | 2026-10-29 | minor | M9, #1075: `figcraft-server` gives requests that change its files (join codes, links, renames, removals, blocks, publishing with the key) a budget of 60 a minute per address, with the server key and owner tokens alike (100 renames in a row from one address: 100 served before, 60 now; another address is unaffected), and finds join codes through an index of their hashes instead of opening every link's code. Same results, same protocol. Tests: `admin_budget_tests`. Numbers unchanged | diff --git a/apps/figcraft-web/Cargo.toml b/apps/figcraft-web/Cargo.toml index aa0fdffb..7b0e27b4 100644 --- a/apps/figcraft-web/Cargo.toml +++ b/apps/figcraft-web/Cargo.toml @@ -19,13 +19,13 @@ figcraft-ui-egui = { workspace = true } figcraft-collab-net = { workspace = true } egui = { workspace = true } log = { workspace = true } +serde_json = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] # `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback. eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] } wasm-bindgen = "0.2" base64 = { workspace = true } -serde_json = { workspace = true } js-sys = "0.3" wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType", "Storage"] } diff --git a/apps/figcraft-web/src/kept.rs b/apps/figcraft-web/src/kept.rs new file mode 100644 index 00000000..4cc8dd27 --- /dev/null +++ b/apps/figcraft-web/src/kept.rs @@ -0,0 +1,39 @@ +//! What the page keeps across reloads, in the browser's local storage: your name (Preferences ▸ +//! Your name), so a reload or a later visit joins live sessions and comments as you. + +use figcraft_engine::Session; +use serde_json::json; + +/// Put back the name the page kept, checked as one typed in Preferences: the page's +/// storage is anyone's to change. +pub fn restore_name(session: &mut Session, kept: Option) { + if let Some(name) = kept + && let Err(e) = session.execute("pref.userName", &json!({"name": name})) + { + log::warn!("the kept name wasn't used: {e}"); + } +} + +#[cfg(test)] +mod tests { + use figcraft_engine::Session; + use serde_json::json; + + use super::restore_name; + + /// What one page kept, the next one restores; junk in the storage is cleaned like typing. + #[test] + fn the_name_survives_a_reload() { + let mut before = Session::new(); + before.execute("pref.userName", &json!({"name": " Alice "})).unwrap(); + let kept = before.prefs.user_name.clone(); + let mut after = Session::new(); + restore_name(&mut after, Some(kept)); + assert_eq!(after.prefs.user_name, "Alice"); + let mut fresh = Session::new(); + restore_name(&mut fresh, None); + assert_eq!(fresh.prefs.user_name, Session::new().prefs.user_name, "nothing kept: the default"); + restore_name(&mut fresh, Some(format!("Bob\u{7}{}", "x".repeat(500)))); + assert!(fresh.prefs.user_name.starts_with("Bobx") && fresh.prefs.user_name.chars().count() == 64); + } +} diff --git a/apps/figcraft-web/src/main.rs b/apps/figcraft-web/src/main.rs index 063a501e..f03cd15b 100644 --- a/apps/figcraft-web/src/main.rs +++ b/apps/figcraft-web/src/main.rs @@ -8,6 +8,7 @@ //! - no TCP control server (browsers can't listen on sockets); //! - no native file dialogs yet: the session starts with one new document; //! - links open in a new browser tab; +//! - of the preferences, the page keeps only your name across reloads (`kept.rs`); //! - live sessions: it joins sessions a desktop app hosts (File ▸ Join live session…) but can't //! host one. A page served over `https://` can only reach sessions on the same computer //! (browsers block `ws://` from secure pages); serve it over `http://` on a trusted network to @@ -24,6 +25,8 @@ #[cfg(target_arch = "wasm32")] mod api; #[cfg(any(target_arch = "wasm32", test))] +mod kept; +#[cfg(any(target_arch = "wasm32", test))] mod query; #[cfg(test)] mod query_tests; diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index a938bf0f..0e4376a9 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -6,6 +6,8 @@ use wasm_bindgen::JsCast as _; const CANVAS_ID: &str = "figcraft_canvas"; const LOADING_ID: &str = "figcraft_loading"; +/// The local storage key your name is kept under ([`crate::kept`]). +const NAME_KEY: &str = "figcraft.userName"; pub fn start() { eframe::WebLogger::init(log::LevelFilter::Info).ok(); @@ -36,6 +38,7 @@ pub fn start() { } let reduce_motion = web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten()); let mut session = Session::new(); + crate::kept::restore_name(&mut session, local_storage().and_then(|s| s.get_item(NAME_KEY).ok().flatten())); // Live sessions: the browser joins sessions a desktop app hosts; whatever arrives // on the socket wakes the page for a frame. let ctx = cc.egui_ctx.clone(); @@ -67,6 +70,11 @@ pub fn start() { }); } +/// The page's local storage (`None` where the browser keeps none, e.g. storage turned off). +fn local_storage() -> Option { + web_sys::window().and_then(|w| w.local_storage().ok().flatten()) +} + fn query() -> String { web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default() } @@ -153,6 +161,12 @@ impl Services for WebServices { fn reduce_motion(&self) -> bool { self.reduce_motion.as_ref().is_some_and(web_sys::MediaQueryList::matches) } + /// Only your name is kept ([`crate::kept`]): the rest starts afresh with each page. + fn save_prefs(&mut self, prefs: &figcraft_engine::Prefs) { + if let Some(Err(e)) = local_storage().map(|s| s.set_item(NAME_KEY, &prefs.user_name)) { + log::warn!("couldn't keep your name: {e:?}"); + } + } /// The browser's language (`navigator.language`). fn system_language(&self) -> Option { web_sys::window().and_then(|w| w.navigator().language()) diff --git a/crates/collab-net/src/lib.rs b/crates/collab-net/src/lib.rs index 256be6ac..c7bc2567 100644 --- a/crates/collab-net/src/lib.rs +++ b/crates/collab-net/src/lib.rs @@ -63,6 +63,8 @@ mod links_tests; #[cfg(test)] mod loopback_tests; #[cfg(test)] +mod rename_tests; +#[cfg(test)] mod session_tests; #[cfg(test)] mod sessions_tests; diff --git a/crates/collab-net/src/rename_tests.rs b/crates/collab-net/src/rename_tests.rs new file mode 100644 index 00000000..5e36563e --- /dev/null +++ b/crates/collab-net/src/rename_tests.rs @@ -0,0 +1,34 @@ +//! Your name changed in Preferences while in a live session: everyone there sees the new one, +//! people who join later too, and a rename made while joining arrives once the host reads it +//! (#1380). + +use serde_json::json; + +use crate::session_tests::{host_and_join, pump, session}; + +#[test] +fn a_rename_reaches_everyone_in_the_session() { + let (mut host, mut guest, hana, ada) = host_and_join(); + pump(&mut host, &mut guest, "both told signals work", |h, g| h.live().signals && g.live().signals); + host.message = None; + guest.execute("pref.userName", &json!({"name": "Bob"})).unwrap(); + assert_eq!(guest.live().info.as_ref().unwrap().name, "Bob", "you see it at once"); + pump(&mut host, &mut guest, "the guest's new name at the host", |h, _| h.live().peers[&ada].name == "Bob"); + assert_eq!(host.message, None, "a rename isn't announced as someone joining"); + host.execute("pref.userName", &json!({"name": "Kim"})).unwrap(); + pump(&mut host, &mut guest, "the host's new name at the guest", |_, g| g.live().peers[&hana].name == "Kim"); + assert_eq!(guest.live().info.as_ref().unwrap().name, "Bob", "the server confirmed the guest's"); + // Renamed while joining: the hello had the old name, the new one follows once signals work. + let mut late = session("Lin"); + late.execute("collab.join", &json!({"link": host.live().info.as_ref().unwrap().link.clone()})).unwrap(); + late.execute("pref.userName", &json!({"name": "Lou"})).unwrap(); + pump(&mut host, &mut late, "the late joiner's name", |h, l| { + h.live().peers.values().any(|p| p.name == "Lou") && l.live().info.as_ref().is_some_and(|i| i.name == "Lou") + }); + let names: Vec<&str> = late.live().peers.values().map(|p| p.name.as_str()).collect(); + assert!(names.contains(&"Kim") && names.contains(&"Bob"), "newcomers get the new names: {names:?}"); + // Blank goes back to the default for comments; the session keeps the name it shows. + guest.execute("pref.userName", &json!({"name": " "})).unwrap(); + assert_eq!(guest.live().info.as_ref().unwrap().name, "Bob"); + host.execute("collab.stop", &json!({})).unwrap(); +} diff --git a/crates/collab-net/src/session_tests.rs b/crates/collab-net/src/session_tests.rs index 32920123..4d5680ee 100644 --- a/crates/collab-net/src/session_tests.rs +++ b/crates/collab-net/src/session_tests.rs @@ -12,7 +12,7 @@ use serde_json::{Value, json}; use crate::NetLink; use crate::loopback_tests::quick; -fn session(name: &str) -> Session { +pub(crate) fn session(name: &str) -> Session { let mut s = Session::new(); s.live.install(Box::new(NetLink::new(Arc::new(|| {})).with_guest_config(quick))); s.execute("pref.userName", &json!({"name": name})).unwrap(); @@ -20,7 +20,7 @@ fn session(name: &str) -> Session { } /// Pump both sessions (as their UIs would every frame) until `f` holds. -fn pump(a: &mut Session, b: &mut Session, what: &str, f: impl Fn(&Session, &Session) -> bool) { +pub(crate) fn pump(a: &mut Session, b: &mut Session, what: &str, f: impl Fn(&Session, &Session) -> bool) { let start = Instant::now(); loop { a.live_poll(); @@ -35,7 +35,7 @@ fn pump(a: &mut Session, b: &mut Session, what: &str, f: impl Fn(&Session, &Sess } } -fn host_and_join() -> (Session, Session, u32, u32) { +pub(crate) fn host_and_join() -> (Session, Session, u32, u32) { let mut host = session("Hana"); let mut guest = session("Ada"); let r = host.execute("collab.host", &json!({"port": 0})).unwrap(); diff --git a/crates/collab/src/lib.rs b/crates/collab/src/lib.rs index 1f8c0f1d..009993cd 100644 --- a/crates/collab/src/lib.rs +++ b/crates/collab/src/lib.rs @@ -149,6 +149,8 @@ mod owned_tests; #[cfg(test)] mod reconnect_tests; #[cfg(test)] +mod rename_tests; +#[cfg(test)] mod server_tests; #[cfg(test)] mod signal_tests; diff --git a/crates/collab/src/message.rs b/crates/collab/src/message.rs index a6346162..05bcba98 100644 --- a/crates/collab/src/message.rs +++ b/crates/collab/src/message.rs @@ -134,9 +134,11 @@ pub struct EditingText { /// ignored: a server drops them, a client skips them. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct Signal { - /// [`Signal::CHAT`], [`Signal::SPOTLIGHT`] or (server to client) [`Signal::READY`]. + /// [`Signal::CHAT`], [`Signal::SPOTLIGHT`], (client to server) [`Signal::NAME`] or (server to + /// client) [`Signal::READY`]. pub kind: String, - /// Chat: the message (empty: closed). Spotlight: `"on"`, or empty when it stopped. + /// Chat: the message (empty: closed). Spotlight: `"on"`, or empty when it stopped. Name: the + /// new name. pub text: String, } @@ -147,6 +149,11 @@ impl Signal { pub const SPOTLIGHT: &str = "spotlight"; /// From the server, about the client itself, right after its welcome: signals may be sent. pub const READY: &str = "ready"; + /// To the server: the client was renamed. The server keeps the new name and tells everyone, + /// the client too, with its [`ServerMsg::PeerJoined`] (it isn't passed on as a signal). + pub const NAME: &str = "name"; + /// Longest name shown to others (characters). + pub const MAX_NAME: usize = 64; /// Longest chat message (characters). pub const MAX_CHAT: usize = 100; /// Longest signal on the wire before it is cleaned (bytes of kind and text together). @@ -164,14 +171,19 @@ impl Signal { Signal { kind: Signal::READY.into(), text: String::new() } } + pub fn name(name: &str) -> Signal { + Signal { kind: Signal::NAME.into(), text: name.into() } + } + /// A spotlight that is on. pub fn on(&self) -> bool { self.text == "on" } /// Untrusted input made safe to pass on or show: `None` for an unknown kind, a malformed - /// spotlight or anything oversized; a chat's control characters become spaces and it is cut to - /// [`MAX_CHAT`](Self::MAX_CHAT) characters (an all-blank one is empty: closed). + /// spotlight, a blank name or anything oversized; a chat's control characters become spaces and + /// it is cut to [`MAX_CHAT`](Self::MAX_CHAT) characters (an all-blank one is empty: closed); a + /// name is trimmed, loses its control characters and is cut to [`MAX_NAME`](Self::MAX_NAME). pub fn clean(self) -> Option { if self.kind.len() + self.text.len() > Signal::MAX_BYTES { return None; @@ -183,6 +195,11 @@ impl Signal { } Signal::SPOTLIGHT if self.text.is_empty() || self.on() => Some(self), Signal::READY if self.text.is_empty() => Some(self), + Signal::NAME => { + let name: String = self.text.trim().chars().filter(|c| !c.is_control()).take(Signal::MAX_NAME).collect(); + let name = name.trim_end(); + (!name.is_empty()).then(|| Signal::name(name)) + } _ => None, } } diff --git a/crates/collab/src/rename_tests.rs b/crates/collab/src/rename_tests.rs new file mode 100644 index 00000000..807ec093 --- /dev/null +++ b/crates/collab/src/rename_tests.rs @@ -0,0 +1,44 @@ +//! A client renamed in a session (a `name` signal): the server keeps the new name and tells +//! everyone with the client's `PeerJoined`; newcomers' welcomes list it (#1380). + +use figcraft_doc::Document; + +use crate::message::{ClientMsg, Hello, Peer, ServerMsg, Signal, client_version}; +use crate::{Client, Limits, Outgoing, Server}; + +fn hello(name: &str) -> Hello { + let ClientMsg::Hello(h) = Client::hello("f", "", name, "#FF8800") else { panic!("not a hello") }; + Hello { client_version: client_version(), ..h } +} + +fn rename(s: &mut Server, from: u32, name: &str) -> Vec { + s.receive(from, ClientMsg::Signal(Signal::name(name))) +} + +#[test] +fn the_new_name_goes_to_everyone_and_to_newcomers() { + let mut s = Server::new(Document::new("Shared"), "", Limits::default()); + let (ada, _) = s.join(hello("Ada")).unwrap(); + s.join(hello("Hana")).unwrap(); + let out = rename(&mut s, ada, " Bob\u{7} "); + let bob = Peer { client_id: ada, name: "Bob".into(), color: "#FF8800".into() }; + assert_eq!(out, vec![Outgoing::All(ServerMsg::PeerJoined(bob))], "cleaned, to everyone, Ada too"); + let (_, out) = s.join(hello("Lin")).unwrap(); + let Some(Outgoing::To(_, ServerMsg::Welcome { peers, .. })) = out.first() else { panic!("no welcome") }; + assert!(peers.iter().any(|p| p.client_id == ada && p.name == "Bob")); + // The same name, a blank one, a stranger's and a name past the server's limit change nothing. + assert!(rename(&mut s, ada, "Bob").is_empty()); + assert!(rename(&mut s, ada, " \n ").is_empty()); + assert!(!rename(&mut s, 999, "Eve").iter().any(|o| matches!(o, Outgoing::All(ServerMsg::PeerJoined(_))))); + let mut tight = Server::new(Document::new("Shared"), "", Limits { max_name: 7, ..Limits::default() }); + let (id, _) = tight.join(hello("Ada")).unwrap(); + assert!(rename(&mut tight, id, "Long name").is_empty()); + assert_eq!(rename(&mut tight, id, "Bo").len(), 1); +} + +#[test] +fn a_name_is_cut_to_the_longest_shown() { + let long = "é".repeat(Signal::MAX_NAME + 10); + assert_eq!(Signal::name(&long).clean().map(|s| s.text.chars().count()), Some(Signal::MAX_NAME)); + assert_eq!(Signal::name("\t").clean(), None); +} diff --git a/crates/collab/src/server.rs b/crates/collab/src/server.rs index 0f3e8c9f..1af5861d 100644 --- a/crates/collab/src/server.rs +++ b/crates/collab/src/server.rs @@ -418,6 +418,9 @@ impl Server { // current spotlight. ClientMsg::Signal(sig) => { let Some(sig) = sig.clean().filter(|s| s.kind != Signal::READY) else { return Vec::new() }; + if sig.kind == Signal::NAME { + return self.rename(from, sig.text); + } if sig.kind == Signal::SPOTLIGHT { if sig.on() { self.spotlight = Some(from); @@ -447,6 +450,15 @@ impl Server { } } + /// Client `from` is called `name` from now on (cleaned): everyone, itself too, hears it as its + /// `PeerJoined`, and newcomers' welcomes list it so. + fn rename(&mut self, from: u32, name: String) -> Vec { + let max = self.limits.max_name; + let Some(peer) = self.peers.get_mut(&from).filter(|p| p.name != name && name.chars().count() <= max) else { return Vec::new() }; + peer.name = name; + vec![Outgoing::All(ServerMsg::PeerJoined(peer.clone()))] + } + /// Keep an accepted batch in the log (dropping the oldest beyond the limits). fn remember(&mut self, acc: Accepted) { self.log_changes += acc.changes.len(); diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index f8cde2e3..a55ea2e2 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -394,12 +394,19 @@ pub fn specs() -> Vec { s.prefs.nudge_big = num(p, "big").unwrap_or(s.prefs.nudge_big).clamp(0.01, 10000.0); Ok(json!({"small": s.prefs.nudge_small, "big": s.prefs.nudge_big})) }), - spec!("pref.userName", "Your name in comments", "{name (blank: the default, \"You\")}", false, |s, p| { - let name = req_string("pref.userName", p, "name")?; - let name: String = name.trim().chars().filter(|c| !c.is_control()).take(64).collect(); - s.prefs.user_name = if name.is_empty() { super::comment::DEFAULT_AUTHOR.into() } else { name }; - Ok(json!({"name": s.prefs.user_name})) - }), + spec!( + "pref.userName", + "Your name", + "{name (blank: the default: \"You\" on comments, \"Guest N\" in live sessions)} → {name}: what your comments are signed with and others see you as in live sessions; the live sessions you're in show the new name at once (blank keeps the name they gave you until you join again)", + false, + |s, p| { + let name = req_string("pref.userName", p, "name")?; + let name: String = name.trim().chars().filter(|c| !c.is_control()).take(crate::live::MAX_NAME).collect(); + s.prefs.user_name = if name.is_empty() { super::comment::DEFAULT_AUTHOR.into() } else { name }; + s.live_rename(); + Ok(json!({"name": s.prefs.user_name})) + } + ), spec!( "pref.publishServer", "Server for online sharing", diff --git a/crates/engine/src/sharing/live/events.rs b/crates/engine/src/sharing/live/events.rs index f738e871..c556610f 100644 --- a/crates/engine/src/sharing/live/events.rs +++ b/crates/engine/src/sharing/live/events.rs @@ -2,7 +2,7 @@ use super::host::{clean_hosting, clean_shared}; use super::types::{Event, PageInfo, Role, SessionId, Status}; use super::{ Access, Arc, Client, ClientMsg, DocState, Document, EngineError, Live, NodeId, PUBLISHING, Peer, Presence, Result, ServerMsg, Session, Signal, - ToolId, VIEW_ONLY, editing_hint, editing_of, sanitize, + ToolId, VIEW_ONLY, display_name, editing_hint, editing_of, sanitize, }; /// What a live session's document holding components that contain each other's instances shows @@ -11,6 +11,22 @@ fn loop_notice(names: &str) -> String { format!("Components contain each other's instances ({names}): those instances stop where they would show themselves") } +/// A session that starts reconnecting forgets who's here (that comes again with the next welcome; +/// edits wait for it), and the host's signal support until it says it reads them again (a +/// spotlight of yours goes out again then). +fn reset_for_reconnect(live: &mut Live) { + live.peers.clear(); + live.presence.clear(); + live.sent = None; + live.signals = false; + live.chats.clear(); + live.chat.clear(); + live.spotlight = None; + if let Some(c) = live.client.as_mut() { + c.disconnect(); + } +} + impl Session { /// The active document (`None`: no document is open). pub(super) fn tab(&self) -> Option { @@ -139,20 +155,8 @@ impl Session { self.live.end(id); } Event::Status(st) => { - // Who's here comes again with the next welcome; edits wait for it. if let Status::Reconnecting { .. } = st { - live.peers.clear(); - live.presence.clear(); - live.sent = None; - // Until the host says it reads them again (a spotlight of yours goes out - // again then). - live.signals = false; - live.chats.clear(); - live.chat.clear(); - live.spotlight = None; - if let Some(c) = live.client.as_mut() { - c.disconnect(); - } + reset_for_reconnect(live); } live.status = st; } @@ -173,7 +177,10 @@ impl Session { live.sent = None; } Event::PeerJoined(p) => { - self.message = Some(format!("{} joined{behind}", p.name)); + // Someone already here was renamed: no newcomer to announce. + if !live.peers.contains_key(&p.client_id) { + self.message = Some(format!("{} joined{behind}", p.name)); + } live.peers.insert(p.client_id, p); } Event::PeerLeft(peer) => { @@ -246,6 +253,9 @@ impl Session { if live.spotlighting { self.live.send(id, [ClientMsg::Signal(Signal::spotlight(true))]); } + // Renamed while away, or before the session read signals: the host's hello had + // the old name. + self.live_send_name(id); } (Signal::CHAT, Some(_)) if sig.text.is_empty() => { live.chats.remove(&peer); @@ -272,6 +282,28 @@ impl Session { } } + /// Your name from Preferences changed: every session you're in shows you by it. + pub(crate) fn live_rename(&mut self) { + let ids: Vec = self.live.sessions.keys().copied().collect(); + for id in ids { + self.live_send_name(id); + } + } + + /// Tell session `id` your name from Preferences when it shows you by another one. The default + /// name isn't sent: the name the session gave you ("Guest N") stays. A host older than + /// renaming ignores it. + fn live_send_name(&mut self, id: SessionId) { + let name = display_name(&self.prefs); + let Some(live) = self.live.sessions.get_mut(&id) else { return }; + if name.is_empty() || !live.signals { + return; + } + let Some(info) = live.info.as_mut().filter(|i| i.name != name) else { return }; + info.name.clone_from(&name); + self.live.send(id, [ClientMsg::Signal(Signal::name(&name))]); + } + /// Why cursor chat or a spotlight can't go out now, if it can't. pub(super) fn signal_blocker(&self) -> Option<&'static str> { let live = self.live(); diff --git a/crates/engine/src/sharing/live/types.rs b/crates/engine/src/sharing/live/types.rs index efeecac9..2040f6d8 100644 --- a/crates/engine/src/sharing/live/types.rs +++ b/crates/engine/src/sharing/live/types.rs @@ -18,7 +18,7 @@ pub const DEFAULT_SERVER: &str = match option_env!("FIGCRAFT_DEFAULT_SERVER") { pub const MAX_SELECTION: usize = 10_000; /// Longest name shown to others (characters). -pub const MAX_NAME: usize = 64; +pub const MAX_NAME: usize = Signal::MAX_NAME; /// The colours people get in a session, in the order the host hands them out (`#RRGGBB`, white /// text reads on each). diff --git a/crates/ui-egui/locales/ar.toml b/crates/ui-egui/locales/ar.toml index a8f114fa..b3ce8421 100644 --- a/crates/ui-egui/locales/ar.toml +++ b/crates/ui-egui/locales/ar.toml @@ -1645,7 +1645,6 @@ status = "draft — needs native review" "Your copy of the file stays open" = "تبقى نسختك من الملف مفتوحة" "Your copy stays open; the file stays on the server" = "تبقى نسختك مفتوحة؛ ويبقى الملف على الخادم" "Your name" = "اسمك" -"Your name in comments" = "اسمك في التعليقات" "Your system asks for reduced motion: animations stay off." = "يطلب نظامك تقليل الحركة؛ تبقى التحريكات متوقفة." "Z" = "Z" "Z to A" = "من Z إلى A" @@ -2196,7 +2195,7 @@ status = "draft — needs native review" "pref.showDimensions" = "إظهار الأبعاد على الكائنات" "pref.startOnHome" = "البدء من الشاشة الرئيسية" "pref.uiScale" = "حجم الواجهة" -"pref.userName" = "اسمك في التعليقات" +"pref.userName" = "اسمك" "proto.action.add" = "إضافة إجراء للنموذج التفاعلي" "proto.action.move" = "إعادة ترتيب إجراء النموذج التفاعلي" "proto.action.remove" = "إزالة إجراء النموذج التفاعلي" diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml index be71f7a6..d30d906e 100644 --- a/crates/ui-egui/locales/bn.toml +++ b/crates/ui-egui/locales/bn.toml @@ -1589,7 +1589,6 @@ status = "draft — needs native review" "Your copy of the file stays open" = "ফাইলের আপনার কপিটি খোলা থাকে" "Your copy stays open; the file stays on the server" = "আপনার কপি খোলা থাকে; ফাইলটি সার্ভারেই থাকে" "Your name" = "আপনার নাম" -"Your name in comments" = "মন্তব্যে আপনার নাম" "Your system asks for reduced motion: animations stay off." = "আপনার সিস্টেম কম মোশনের অনুরোধ জানায়: অ্যানিমেশন বন্ধ থাকে।" "Z" = "Z" "Z to A" = "Z থেকে A" diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index c6158f80..d755ee6f 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -1555,7 +1555,6 @@ status = "draft — needs native review" "Your copy of the file stays open" = "Vaše kopie souboru zůstane otevřená" "Your copy stays open; the file stays on the server" = "Vaše kopie zůstane otevřená; soubor zůstane na serveru" "Your name" = "Vaše jméno" -"Your name in comments" = "Vaše jméno v komentářích" "Your system asks for reduced motion: animations stay off." = "Váš systém požaduje omezení pohybu: animace zůstanou vypnuté." "Z" = "Z" "Z to A" = "Od Z do A" @@ -2088,7 +2087,7 @@ status = "draft — needs native review" "pref.reduceMotion" = "Omezit pohyb" "pref.renameDuplicates" = "Přejmenovávat duplikované vrstvy" "pref.showDimensions" = "Zobrazit rozměry objektů" -"pref.userName" = "Vaše jméno v komentářích" +"pref.userName" = "Vaše jméno" "proto.action.add" = "Přidat akci prototypu" "proto.action.move" = "Změnit pořadí akce prototypu" "proto.action.remove" = "Odebrat akci prototypu" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 83a5ca3c..c6d6b602 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -1516,7 +1516,6 @@ status = "draft — needs native review" "Your copy of the file stays open" = "Ihre Kopie der Datei bleibt geöffnet" "Your copy stays open; the file stays on the server" = "Ihre Kopie bleibt geöffnet; die Datei bleibt auf dem Server" "Your name" = "Ihr Name" -"Your name in comments" = "Ihr Name in Kommentaren" "Your system asks for reduced motion: animations stay off." = "Ihr System verlangt reduzierte Bewegung: Animationen bleiben aus." "Z" = "Z" "Zoom" = "Zoom" @@ -2042,7 +2041,7 @@ status = "draft — needs native review" "pref.renameDuplicates" = "Duplizierte Ebenen umbenennen" "pref.showDimensions" = "Abmessungen an Objekten anzeigen" "pref.startOnHome" = "Mit Start beginnen" -"pref.userName" = "Ihr Name in Kommentaren" +"pref.userName" = "Ihr Name" "proto.action.add" = "Prototyp-Aktion hinzufügen" "proto.action.move" = "Prototyp-Aktion neu anordnen" "proto.action.remove" = "Prototyp-Aktion entfernen" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 1922669c..b477a4a1 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1656,7 +1656,6 @@ name = "English" "Your copy of the file stays open" = "Your copy of the file stays open" "Your copy stays open; the file stays on the server" = "Your copy stays open; the file stays on the server" "Your name" = "Your name" -"Your name in comments" = "Your name in comments" "Your system asks for reduced motion: animations stay off." = "Your system asks for reduced motion: animations stay off." "Z" = "Z" "Z to A" = "Z to A" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 9cc288d3..d7f7c7c4 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1633,7 +1633,6 @@ status = "draft — needs native review" "Your copy of the file stays open" = "Tu copia del archivo sigue abierta" "Your copy stays open; the file stays on the server" = "Tu copia sigue abierta; el archivo se queda en el servidor" "Your name" = "Tu nombre" -"Your name in comments" = "Tu nombre en los comentarios" "Your system asks for reduced motion: animations stay off." = "Tu sistema pide reducir el movimiento: las animaciones siguen desactivadas." "Z" = "Z" "Z to A" = "De la Z a la A" @@ -2185,7 +2184,7 @@ status = "draft — needs native review" "pref.showDimensions" = "Mostrar dimensiones en los objetos" "pref.startOnHome" = "Empezar en Inicio" "pref.uiScale" = "Tamaño de la interfaz" -"pref.userName" = "Tu nombre en los comentarios" +"pref.userName" = "Tu nombre" "proto.action.add" = "Añadir acción de prototipo" "proto.action.move" = "Reordenar acción de prototipo" "proto.action.remove" = "Quitar acción de prototipo" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index ef5c3c94..befe2ae9 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -1594,7 +1594,6 @@ status = "draft — needs native review" "Your copy of the file stays open" = "Oma kopiosi tiedostosta pysyy auki" "Your copy stays open; the file stays on the server" = "Oma kopiosi pysyy auki, ja tiedosto jää palvelimelle" "Your name" = "Nimesi" -"Your name in comments" = "Nimesi kommenteissa" "Your system asks for reduced motion: animations stay off." = "Järjestelmäsi pyytää vähennettyä liikettä. Animaatiot pidetään pois käytöstä." "Z" = "Z" "Z to A" = "Z:sta A:han" @@ -2134,7 +2133,7 @@ status = "draft — needs native review" "pref.renameDuplicates" = "Nimeä monistetut tasot uudelleen" "pref.showDimensions" = "Näytä objektien mitat" "pref.startOnHome" = "Aloita Aloitus-näkymästä" -"pref.userName" = "Nimesi kommenteissa" +"pref.userName" = "Nimesi" "proto.action.add" = "Lisää prototyyppitoiminto" "proto.action.move" = "Muuta prototyyppitoimintojen järjestystä" "proto.action.remove" = "Poista prototyyppitoiminto" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index b800e0f5..327fbd4c 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -1597,7 +1597,6 @@ status = "draft — needs native review" "Your copy of the file stays open" = "Votre copie du fichier reste ouverte" "Your copy stays open; the file stays on the server" = "Votre copie reste ouverte ; le fichier reste sur le serveur" "Your name" = "Votre nom" -"Your name in comments" = "Votre nom dans les commentaires" "Your system asks for reduced motion: animations stay off." = "Votre système demande de réduire les animations : elles restent désactivées." "Z" = "Z" "Z to A" = "Z à A" @@ -2136,7 +2135,7 @@ status = "draft — needs native review" "pref.showDimensions" = "Afficher les dimensions sur les objets" "pref.startOnHome" = "Démarrer sur l’Accueil" "pref.uiScale" = "Taille de l’interface" -"pref.userName" = "Votre nom dans les commentaires" +"pref.userName" = "Votre nom" "proto.action.add" = "Ajouter une action de prototype" "proto.action.move" = "Réordonner l’action de prototype" "proto.action.remove" = "Retirer l’action de prototype" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index 76206f48..896b8d87 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -1559,7 +1559,6 @@ status = "draft — needs native review" "Your copy of the file stays open" = "Salinan file Anda tetap terbuka" "Your copy stays open; the file stays on the server" = "Salinan Anda tetap terbuka; file tetap ada di server" "Your name" = "Nama Anda" -"Your name in comments" = "Nama Anda di komentar" "Your system asks for reduced motion: animations stay off." = "Sistem Anda meminta gerakan dikurangi: animasi tetap nonaktif." "Z" = "Z" "Z to A" = "Z ke A" @@ -2092,7 +2091,7 @@ status = "draft — needs native review" "pref.reduceMotion" = "Kurangi gerakan" "pref.renameDuplicates" = "Ganti nama layer duplikat" "pref.showDimensions" = "Tampilkan dimensi pada objek" -"pref.userName" = "Nama Anda di komentar" +"pref.userName" = "Nama Anda" "proto.action.add" = "Tambah aksi prototype" "proto.action.move" = "Ubah urutan aksi prototype" "proto.action.remove" = "Hapus aksi prototype" diff --git a/crates/ui-egui/locales/it.toml b/crates/ui-egui/locales/it.toml index c82ee628..ab5e9cfd 100644 --- a/crates/ui-egui/locales/it.toml +++ b/crates/ui-egui/locales/it.toml @@ -1533,7 +1533,6 @@ status = "draft — needs native review" "Your copy of the file stays open" = "La tua copia del file resta aperta" "Your copy stays open; the file stays on the server" = "La tua copia resta aperta; il file resta sul server" "Your name" = "Il tuo nome" -"Your name in comments" = "Il tuo nome nei commenti" "Your system asks for reduced motion: animations stay off." = "Il tuo sistema richiede movimento ridotto: le animazioni restano disattivate." "Z" = "Z" "Z to A" = "Dalla Z alla A" @@ -2049,7 +2048,7 @@ status = "draft — needs native review" "pref.showDimensions" = "Mostra dimensioni sugli oggetti" "pref.startOnHome" = "Avvia dalla schermata Home" "pref.uiScale" = "Dimensione dell'interfaccia" -"pref.userName" = "Il tuo nome nei commenti" +"pref.userName" = "Il tuo nome" "proto.action.add" = "Aggiungi azione del prototipo" "proto.action.move" = "Riordina azione del prototipo" "proto.action.remove" = "Rimuovi azione del prototipo" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 244865e6..f5b083d0 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -1578,7 +1578,6 @@ status = "draft — needs native review" "Your copy of the file stays open" = "あなたのファイルのコピーは開いたまま" "Your copy stays open; the file stays on the server" = "手元のコピーは開いたまま。ファイルはサーバーに残る" "Your name" = "あなたの名前" -"Your name in comments" = "コメントに表示する名前" "Your system asks for reduced motion: animations stay off." = "システムで動きを減らす設定がオンのため、アニメーションはオフのまま。" "Z" = "Z" "Z to A" = "降順(Z→A)" @@ -2114,7 +2113,7 @@ status = "draft — needs native review" "pref.renameDuplicates" = "複製したレイヤーの名前を変更" "pref.showDimensions" = "オブジェクトに寸法を表示" "pref.startOnHome" = "起動時にホームを表示" -"pref.userName" = "コメントに表示する名前" +"pref.userName" = "あなたの名前" "proto.action.add" = "プロトタイプのアクションを追加" "proto.action.move" = "プロトタイプのアクションの順序を変更" "proto.action.remove" = "プロトタイプのアクションを削除" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index cc21e9ff..6986376b 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -1615,7 +1615,6 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Your copy of the file stays open" = "თქვენი ფაილის ასლი ღია რჩება" "Your copy stays open; the file stays on the server" = "თქვენი ასლი ღია რჩება; ფაილი სერვერზე რჩება" "Your name" = "თქვენი სახელი" -"Your name in comments" = "თქვენი სახელი კომენტარებში" "Your system asks for reduced motion: animations stay off." = "თქვენი სისტემა ითხოვს მოძრაობის შემცირებას: ანიმაციები გამორთული რჩება." "Z" = "Z" "Z to A" = "ჰ-დან ა-მდე" @@ -2188,7 +2187,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "pref.showDimensions" = "ზომების ჩვენება ობიექტებზე" "pref.startOnHome" = "საწყისი ეკრანით გაშვება" "pref.uiScale" = "ინტერფეისის ზომა" -"pref.userName" = "თქვენი სახელი კომენტარებში" +"pref.userName" = "თქვენი სახელი" "proto.action.add" = "პროტოტიპის მოქმედების დამატება" "proto.action.move" = "პროტოტიპის მოქმედების გადალაგება" "proto.action.remove" = "პროტოტიპის მოქმედების წაშლა" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index d10dbdc3..f0c1a3e4 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -1507,7 +1507,6 @@ status = "draft — needs native review" "Your copy of the file stays open" = "내 파일 사본은 열린 상태로 유지됨" "Your copy stays open; the file stays on the server" = "내 사본은 열린 채로 유지됨. 파일은 서버에 남음" "Your name" = "내 이름" -"Your name in comments" = "댓글에 표시할 이름" "Your system asks for reduced motion: animations stay off." = "시스템에서 동작 줄이기를 요청하므로 애니메이션이 꺼져 있음." "Z" = "Z" "Zoom" = "확대/축소" @@ -2148,7 +2147,7 @@ status = "draft — needs native review" "pref.renameDuplicates" = "복제한 레이어 이름 바꾸기" "pref.showDimensions" = "개체에 치수 표시" "pref.startOnHome" = "홈에서 시작" -"pref.userName" = "댓글에 표시할 이름" +"pref.userName" = "내 이름" "proto.action.add" = "프로토타입 액션 추가" "proto.action.move" = "프로토타입 액션 순서 변경" "proto.action.remove" = "프로토타입 액션 제거" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index 0dd2a9ae..5c8cc3d8 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -1594,7 +1594,6 @@ status = "draft — needs native review" "Your copy of the file stays open" = "Je kopie van het bestand blijft open" "Your copy stays open; the file stays on the server" = "Jouw kopie blijft open; het bestand blijft op de server" "Your name" = "Jouw naam" -"Your name in comments" = "Je naam in reacties" "Your system asks for reduced motion: animations stay off." = "Jouw systeem vraagt om minder beweging: animaties blijven uit." "Z" = "Z" "Z to A" = "Z tot A" @@ -2133,7 +2132,7 @@ status = "draft — needs native review" "pref.renameDuplicates" = "Gedupliceerde lagen hernoemen" "pref.showDimensions" = "Afmetingen op objecten tonen" "pref.startOnHome" = "Starten op Home" -"pref.userName" = "Je naam in reacties" +"pref.userName" = "Jouw naam" "proto.action.add" = "Prototypeactie toevoegen" "proto.action.move" = "Volgorde van prototypeactie wijzigen" "proto.action.remove" = "Prototype-actie verwijderen" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index 46c951f8..90e53724 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -1604,7 +1604,6 @@ name = "Polski" "Your copy of the file stays open" = "Twoja kopia pliku pozostaje otwarta" "Your copy stays open; the file stays on the server" = "Twoja kopia pozostaje otwarta; plik pozostaje na serwerze" "Your name" = "Twoje imię i nazwisko" -"Your name in comments" = "Twoje imię w komentarzach" "Your system asks for reduced motion: animations stay off." = "Twój system preferuje ograniczony ruch: animacje pozostają wyłączone." "Z" = "Z" "Z to A" = "Od Z do A" @@ -2147,7 +2146,7 @@ name = "Polski" "pref.showDimensions" = "Pokazuj wymiary przy zaznaczeniu" "pref.startOnHome" = "Rozpoczynaj od ekranu głównego" "pref.uiScale" = "Rozmiar interfejsu" -"pref.userName" = "Twoja nazwa w komentarzach" +"pref.userName" = "Twoje imię i nazwisko" "proto.action.add" = "Dodaj akcję prototypu" "proto.action.move" = "Zmień kolejność akcji prototypu" "proto.action.remove" = "Usuń akcję prototypu" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 0704bfc5..fbc12f68 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -1505,7 +1505,6 @@ status = "draft — needs native review" "Your copy of the file stays open" = "Sua cópia do arquivo continua aberta" "Your copy stays open; the file stays on the server" = "Sua cópia continua aberta; o arquivo continua no servidor" "Your name" = "Seu nome" -"Your name in comments" = "Seu nome nos comentários" "Your system asks for reduced motion: animations stay off." = "Seu sistema pede movimento reduzido: as animações ficam desativadas." "Z" = "Z" "Zoom" = "Zoom" @@ -2145,7 +2144,7 @@ status = "draft — needs native review" "pref.renameDuplicates" = "Renomear camadas duplicadas" "pref.showDimensions" = "Mostrar dimensões nos objetos" "pref.startOnHome" = "Começar no Início" -"pref.userName" = "Seu nome nos comentários" +"pref.userName" = "Seu nome" "proto.action.add" = "Adicionar ação de protótipo" "proto.action.move" = "Reordenar ação de protótipo" "proto.action.remove" = "Remover ação de protótipo" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index d1e84663..11449a43 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -1604,7 +1604,6 @@ name = "Русский" "Your copy of the file stays open" = "Ваша копия файла останется открытой" "Your copy stays open; the file stays on the server" = "Ваша копия останется открытой; файл останется на сервере" "Your name" = "Ваше имя" -"Your name in comments" = "Ваше имя в комментариях" "Your system asks for reduced motion: animations stay off." = "В системе включено уменьшение движения: анимации отключены." "Z" = "Z" "Z to A" = "От Я до А" @@ -2147,7 +2146,7 @@ name = "Русский" "pref.showDimensions" = "Показать размеры объектов" "pref.startOnHome" = "Начинать с главной" "pref.uiScale" = "Размер интерфейса" -"pref.userName" = "Ваше имя в комментариях" +"pref.userName" = "Ваше имя" "proto.action.add" = "Добавить действие прототипа" "proto.action.move" = "Изменить порядок действия прототипа" "proto.action.remove" = "Удалить действие прототипа" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index 234158c4..9fd05c69 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -1640,7 +1640,6 @@ name = "Svenska" "Your copy of the file stays open" = "Din kopia av filen förblir öppen" "Your copy stays open; the file stays on the server" = "Din kopia förblir öppen; filen ligger kvar på servern" "Your name" = "Ditt namn" -"Your name in comments" = "Ditt namn i kommentarer" "Your system asks for reduced motion: animations stay off." = "Systemet begär minskad rörelse: animeringar förblir avstängda." "Z" = "Z" "Z to A" = "Ö till A" @@ -2192,7 +2191,7 @@ name = "Svenska" "pref.showDimensions" = "Visa dimensioner på objekt" "pref.startOnHome" = "Börja på Start" "pref.uiScale" = "Gränssnittsstorlek" -"pref.userName" = "Ditt namn i kommentarer" +"pref.userName" = "Ditt namn" "proto.action.add" = "Lägg till prototypåtgärd" "proto.action.move" = "Ändra ordning på prototypåtgärd" "proto.action.remove" = "Ta bort prototypåtgärd" diff --git a/crates/ui-egui/locales/th.toml b/crates/ui-egui/locales/th.toml index 6afb9206..d281f598 100644 --- a/crates/ui-egui/locales/th.toml +++ b/crates/ui-egui/locales/th.toml @@ -1584,7 +1584,6 @@ status = "draft — needs native review" "Your copy of the file stays open" = "สำเนาไฟล์ของคุณยังเปิดอยู่" "Your copy stays open; the file stays on the server" = "สำเนาของคุณยังเปิดอยู่ ไฟล์ยังอยู่บนเซิร์ฟเวอร์" "Your name" = "ชื่อของคุณ" -"Your name in comments" = "ชื่อของคุณในความคิดเห็น" "Your system asks for reduced motion: animations stay off." = "ระบบของคุณขอให้ลดการเคลื่อนไหว: แอนิเมชันจะปิดอยู่" "Z" = "Z" "Z to A" = "Z ถึง A" @@ -2110,7 +2109,7 @@ status = "draft — needs native review" "pref.renameDuplicates" = "เปลี่ยนชื่อเลเยอร์ที่ทำสำเนา" "pref.showDimensions" = "แสดงขนาดบนออบเจกต์" "pref.startOnHome" = "เริ่มที่หน้าจอหลัก" -"pref.userName" = "ชื่อของคุณในความคิดเห็น" +"pref.userName" = "ชื่อของคุณ" "proto.action.add" = "เพิ่มการกระทำโปรโตไทป์" "proto.action.move" = "เรียงลำดับการกระทำโปรโตไทป์ใหม่" "proto.action.remove" = "นำการกระทำโปรโตไทป์ออก" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index 0f0f8732..f1dbf07c 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -1559,7 +1559,6 @@ name = "Türkçe" "Your copy of the file stays open" = "Dosyanın kopyanız açık kalır" "Your copy stays open; the file stays on the server" = "Kopyanız açık kalır; dosya sunucuda kalır" "Your name" = "Adınız" -"Your name in comments" = "Yorumlardaki adınız" "Your system asks for reduced motion: animations stay off." = "Sisteminiz hareketin azaltılmasını istiyor: animasyonlar kapalı kalır." "Z" = "Z" "Z to A" = "Z'den A'ya" @@ -2092,7 +2091,7 @@ name = "Türkçe" "pref.reduceMotion" = "Hareketi azalt" "pref.renameDuplicates" = "Çoğaltılan katmanları yeniden adlandır" "pref.showDimensions" = "Nesnelerde boyutları göster" -"pref.userName" = "Yorumlardaki adınız" +"pref.userName" = "Adınız" "proto.action.add" = "Prototip eylemi ekle" "proto.action.move" = "Prototip eylemini yeniden sırala" "proto.action.remove" = "Prototip eylemini kaldır" diff --git a/crates/ui-egui/locales/vi.toml b/crates/ui-egui/locales/vi.toml index 7c9740ac..e8e948e3 100644 --- a/crates/ui-egui/locales/vi.toml +++ b/crates/ui-egui/locales/vi.toml @@ -1534,7 +1534,6 @@ status = "draft — needs native review" "Your copy of the file stays open" = "Bản sao tệp của bạn vẫn mở" "Your copy stays open; the file stays on the server" = "Bản sao của bạn vẫn mở; tệp vẫn được lưu trên máy chủ" "Your name" = "Tên của bạn" -"Your name in comments" = "Tên của bạn trong bình luận" "Your system asks for reduced motion: animations stay off." = "Hệ thống của bạn yêu cầu giảm chuyển động: các hoạt ảnh sẽ được tắt." "Z" = "Z" "Z to A" = "Z đến A" @@ -2050,7 +2049,7 @@ status = "draft — needs native review" "pref.showDimensions" = "Hiện kích thước trên đối tượng" "pref.startOnHome" = "Khởi động tại màn hình Trang chủ" "pref.uiScale" = "Kích thước giao diện" -"pref.userName" = "Tên của bạn trong bình luận" +"pref.userName" = "Tên của bạn" "proto.action.add" = "Thêm hành động nguyên mẫu" "proto.action.move" = "Sắp xếp lại hành động nguyên mẫu" "proto.action.remove" = "Xóa hành động nguyên mẫu" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 946f2604..72c9b585 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -1515,7 +1515,6 @@ status = "draft — needs native review" "Your copy of the file stays open" = "你的文件副本保持打开" "Your copy stays open; the file stays on the server" = "你的副本保持打开;文件保留在服务器上" "Your name" = "你的名字" -"Your name in comments" = "评论中显示的名字" "Your system asks for reduced motion: animations stay off." = "系统要求减少动态效果:动画保持关闭。" "Z" = "Z" "Zoom" = "缩放" @@ -2045,7 +2044,7 @@ status = "draft — needs native review" "pref.renameDuplicates" = "重命名副本图层" "pref.showDimensions" = "在对象上显示尺寸" "pref.startOnHome" = "启动时显示主页" -"pref.userName" = "评论中显示的名字" +"pref.userName" = "你的名字" "proto.action.add" = "添加原型操作" "proto.action.move" = "调整原型操作顺序" "proto.action.remove" = "移除原型操作" diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c6e5829f..c266bebb 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -264,10 +264,8 @@ pub fn show(app: &mut FigApp, ctx: &Context) { } ui.add_space(8.0); ui.horizontal(|ui| { - ui.label(tr("Your name in comments")); - if let Some(name) = crate::widgets::text_field(ui, egui::Id::unique("pref_user_name"), &prefs.user_name, 160.0, tr("You")) { - app.run("pref.userName", json!({"name": name})); - } + ui.label(tr("Your name")); + crate::live::name_field(app, ui, egui::Id::unique("pref_user_name"), 160.0); }); ui.add_space(8.0); ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/display_name_tests.rs b/crates/ui-egui/src/display_name_tests.rs new file mode 100644 index 00000000..25f37a4f --- /dev/null +++ b/crates/ui-egui/src/display_name_tests.rs @@ -0,0 +1,63 @@ +//! Your name, one for comments and live sessions (#1380): "Your name" in Preferences, shown and +//! editable in the Join dialog, and kept as soon as it changes. + +use std::sync::{Arc, Mutex}; + +use egui::{Event, Key, Modifiers}; +use figcraft_engine::Prefs; + +use crate::Services; +use crate::live_tests::{Harness, has_text, text_center}; + +/// Keeps the name of each preferences save. +struct Kept(Arc>>); + +impl Services for Kept { + fn save_prefs(&mut self, prefs: &Prefs) { + self.0.lock().unwrap().push(prefs.user_name.clone()); + } +} + +fn harness() -> (Harness, Arc>>) { + let (mut h, _) = Harness::new(); + let kept = Arc::new(Mutex::new(Vec::new())); + h.app.services = Box::new(Kept(kept.clone())); + (h, kept) +} + +/// Replace the text of the field showing `old` with `new` and press Enter. +fn retype(h: &mut Harness, old: &str, new: &str) { + let shapes = h.frames(1); + h.click(text_center(&shapes, old)); + let key = |key, modifiers| Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + h.frame(vec![key(Key::A, Modifiers::COMMAND)]); + h.frame(vec![Event::Text(new.into())]); + h.frame(vec![key(Key::Enter, Modifiers::NONE)]); + h.frames(1); +} + +#[test] +fn the_join_dialog_shows_your_name_and_changes_it() { + let (mut h, kept) = harness(); + h.app.session.execute("pref.userName", &serde_json::json!({"name": "Ada"})).unwrap(); + crate::menus::invoke(&mut h.app, "collab.join"); + let shapes = h.frames(3); + assert!(has_text(&shapes, "Your name") && has_text(&shapes, "Ada"), "the name others will see"); + retype(&mut h, "Ada", "Bob"); + assert_eq!(h.app.session.prefs.user_name, "Bob"); + assert_eq!(kept.lock().unwrap().last().map(String::as_str), Some("Bob"), "kept at once"); + assert!(h.app.ui.live.join.is_some(), "the dialog stays to join"); +} + +#[test] +fn preferences_call_it_your_name_and_the_default_shows_empty() { + let (mut h, kept) = harness(); + h.app.ui.preferences = true; + let shapes = h.frames(3); + assert!(has_text(&shapes, "Your name") && !has_text(&shapes, "Your name in comments")); + assert!(!has_text(&shapes, "You"), "the default isn't a name to edit"); + h.app.session.execute("pref.userName", &serde_json::json!({"name": "Ada"})).unwrap(); + retype(&mut h, "Ada", "Lin"); + assert_eq!(h.app.session.prefs.user_name, "Lin"); + assert_eq!(kept.lock().unwrap().as_slice(), ["Lin"]); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1d1a8d4b..7c5cd285 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -79,6 +79,8 @@ mod dev_mode_tests; #[cfg(test)] mod devmode_tail_tests; #[cfg(test)] +mod display_name_tests; +#[cfg(test)] mod duplicate_paint_tests; #[cfg(test)] mod export_preview_tests; diff --git a/crates/ui-egui/src/live.rs b/crates/ui-egui/src/live.rs index 28b50f80..31e2f066 100644 --- a/crates/ui-egui/src/live.rs +++ b/crates/ui-egui/src/live.rs @@ -798,13 +798,7 @@ pub(crate) fn failure_text(reason: &str) -> Cow<'_, str> { /// Not in a session: your name, how to share (online or on the local network), start; or join /// one with a code. Everything else is under Advanced. fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { - ui.horizontal(|ui| { - widgets::label(ui, tr("Your name"), t.text); - let name = app.session.prefs.user_name.clone(); - if let Some(n) = widgets::text_field(ui, egui::Id::unique("live_name"), &name, 160.0, tr("Your name")) { - app.run("pref.userName", json!({"name": n})); - } - }); + name_row(app, ui, t, egui::Id::unique("live_name")); if !app.session.live.can_host() { widgets::label(ui, tr("Sharing a file needs the desktop app; here you can join one."), t.text_secondary); widgets::label(ui, tr("Join with a code"), t.text); @@ -876,6 +870,25 @@ fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { }); } +/// "Your name" and its field (the Share popover, the Join dialog). +fn name_row(app: &mut FigApp, ui: &mut Ui, t: &Tokens, id: egui::Id) { + ui.horizontal(|ui| { + widgets::label(ui, tr("Your name"), t.text); + name_field(app, ui, id, 160.0); + }); +} + +/// The field for your name (`pref.userName`), the one name comments are signed with and others +/// see you as in live sessions: empty while it is the default. A new name is kept at once (the +/// browser has no quitting to keep it at). +pub fn name_field(app: &mut FigApp, ui: &mut Ui, id: egui::Id, width: f32) { + let shown = figcraft_engine::live::display_name(&app.session.prefs); + if let Some(name) = widgets::text_field(ui, id, &shown, width, tr("Your name")) { + app.run("pref.userName", json!({"name": name})); + app.services.save_prefs(&app.session.prefs); + } +} + /// Under what starts a share: the join field, then the Advanced part (`advanced` draws it while /// it is open). fn join_and_advanced(app: &mut FigApp, ui: &mut Ui, t: &Tokens, advanced: impl FnOnce(&mut FigApp, &mut Ui, &Tokens)) { @@ -1205,6 +1218,7 @@ pub fn join_dialog(app: &mut FigApp, ctx: &egui::Context) { go = join_field(app, ui, &mut link, egui::Id::unique("live_join_link"), 300.0, focus).is_some(); cancel = widgets::button(ui, tr("Cancel"), false, true).clicked(); }); + name_row(app, ui, &t, egui::Id::unique("live_join_name")); widgets::label(ui, tr("You'll get the shared file as a new tab and edit it together."), t.text_secondary); if !app.session.live.can_host() { widgets::label(ui, tr(BROWSER_NOTE), t.text_secondary); diff --git a/docs/collab-protocol.md b/docs/collab-protocol.md index 868806b4..17f1bc4b 100644 --- a/docs/collab-protocol.md +++ b/docs/collab-protocol.md @@ -198,7 +198,10 @@ an older client never gets a message it can't read. A server that takes them ans client, right after its welcome, with a `ready` signal about the client itself; a client sends signals only after that, so an older server never gets one either (the app says the host runs an older version). A signal is a kind and a text, so kinds added later are skipped by clients and -dropped by servers that don't know them. +dropped by servers that don't know them. The `name` kind (a rename) came after the first signals: +a server that knows it keeps the new name and tells everyone with the client's `PeerJoined` +(apps take a `PeerJoined` for someone already there as their new name and colour); an older +server drops it, so the session keeps showing the name the client joined with. **Open hosting and reports** came later within version 2 the same way. A connection that opens with `OpenHosting` asks a server about its hosting, publishes without the key when the server is @@ -247,7 +250,7 @@ Client → server: | `Admin {publish {name, snapshot} \| files \| links {file} \| createLink {file, access, label} \| revoke {file, link} \| remove {file}}` | A request on a managing connection; each gets one `Admin` reply, in order. | | `OpenLibrary {protocol, file, token, client_version}` | Instead of `Hello`: use a file's team library with one of the file's share links. | | `Library {fetch {have?, usage?} \| publish {archive} \| usage}` | A request on a library connection; each gets one `Library` reply, in order. `usage` is `{file, components: [(id, instances)]}`. | -| `Signal {kind, text}` | Ephemeral presence, only after the server's `ready`: `chat` (the cursor chat message; empty: closed) or `spotlight` (`"on"`, or empty: stopped). | +| `Signal {kind, text}` | Ephemeral presence, only after the server's `ready`: `chat` (the cursor chat message; empty: closed), `spotlight` (`"on"`, or empty: stopped) or `name` (your new name: the server keeps it, cleaned like a hello's, and sends everyone, you too, your `PeerJoined`; never passed on as a signal). | | `OpenPlugins {protocol, key, client_version}` | Instead of `Hello`: use the server's plugin directory; `key` empty to browse, the server key to publish too. | | `OpenHosting {protocol, client_version, file, owner}` | Instead of `Hello`: ask about the server's hosting; publish without the key (`Admin publish`) when it is open to the public; with a file's id and owner token, manage that file (`Admin links \| createLink \| revoke \| rename \| remove` of it). Answered with `Hosting` or `Refused`. | | `Admin {rename {file, name} \| listing \| reports \| block {ip} \| unblock {ip} \| blocked}` | Later managing requests: rename a file on the server, the files with what the operator moderates by, the reports, blocking addresses (an IPv6 address: its /64). Owner tokens may only rename their file. | @@ -438,8 +441,10 @@ resuming with its offline edits, and leaving the session going back to snapshot With another document in front (or before the welcome arrives), cursors show in page coordinates on whatever page you look at and nothing is outlined. - Colours and names: the host hands out the first free colour of an eight-colour palette and - "Guest N" to people without a name (Preferences: your name, `pref.userName`; also in the Share - popover). After a newcomer's `Welcome` the host sends it its own `PeerJoined`, which is how it + "Guest N" to people without a name (Preferences ▸ Your name, `pref.userName`; also in the Share + popover and the Join dialog). Changing it while in a session renames you there at once (a + `name` signal, sent again after a reconnect, whose hello has the old name); clearing it keeps + the session's name until you join again. After a newcomer's `Welcome` the host sends it its own `PeerJoined`, which is how it learns its name and colour. - Idle cost is zero: network threads block, wake the window (`request_repaint`) only when a message arrives, cursors only ask for frames while they glide, and text carets never do. diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index 1091548a..07327054 100644 --- a/docs/collaboration-parity.md +++ b/docs/collaboration-parity.md @@ -1,6 +1,6 @@ # Collaboration parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); 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); comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); 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); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (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); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); 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); comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); 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); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model, @@ -15,7 +15,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). The batches kept for apps that reconnect are bounded in bytes by default (64 MiB; `max_log_mb` on `figcraft-server`), and an app further behind catches up with a snapshot (#1069). Left: server archives beyond Linux | 25–55 | | Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). Placing a layer among its siblings is a binary search, so large pastes and reorders in one batch no longer take quadratic time (#1065). Left: server archives beyond Linux | 25–55 | | Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). A refused batch's fixes take time linear in the batch: each deleted layer is restored once and each entry fix looked up once (#1063). Left: server archives beyond Linux | 25–55 | -| Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors, selection outlines, avatars, follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 | +| Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors (one name for live sessions and comments: Preferences ▸ Your name, also in the Share popover and the Join dialog, kept in the browser too; a rename shows at once in the sessions you're in, #1380), selection outlines, avatars, follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 | | Cursor chat, audio, high-fives | yes | **partial, in live sessions**: cursor chat (`/` opens a bubble at your pointer; everyone sees it as you type, it fades a few seconds after the last change, Enter sends and Escape takes it back, 100 characters; `collab.cursorChat` for agents); sent as ephemeral presence the host checks and rate-limits, never stored. No audio, high-fives or emote reactions | 2–5 | | Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen, and the Join dialog then shows the join until its file is ready (Cancel), or why it failed (Try again, #1381); the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser)); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer: no accounts, teams or projects | 8–17 | | Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences (an AI agent attached over MCP comments as its own name, marked Agent); a behaviour suite from the documentation (pinning and following layers, threads, own edits and deletes, mentions, reactions, C and Shift+C, stepping, filters, saving, copy and paste); restoring a version keeps them. `comment.list` (CLI, MCP) gives each message its `mentions` (`{name, known, you}`, by the highlighting rule). In a live session threads sync and anyone in it can reply, react and resolve (no accounts: a name is who you are) | (shared once multiplayer lands) | @@ -28,6 +28,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M9, #1380: your name is one setting, shown where you join. Preferences calls it "Your name" (it signs your comments and is what others see in live sessions). The Join dialog shows it, editable, beside the code, as the Share popover does, and the field is empty while it is the default. A new name is kept at once, and the browser build keeps it in the page's local storage, so a reload joins as you (the other preferences still start afresh there). Changing it while in live sessions renames you in each of them: a `name` signal, which the server cleans and answers with your `PeerJoined` to everyone, and newcomers' welcomes list the new name. A rename made while joining, or before a reconnect, goes out once the host reads signals. An older server ignores it, and clearing the name keeps the session's name until you join again. Tests: `collab/src/rename_tests.rs`, `collab-net/src/rename_tests.rs` (two apps over loopback), `ui-egui/src/display_name_tests.rs`, and `figcraft-web`'s `kept` round-trip. Numbers unchanged | | 2026-10-29 | minor | M9, #1381: joining a live session shows its progress. From the click on Join (the Share popover, the home screen or the Join dialog, which an invite link opens) the Join dialog says "Joining live session…" with a spinner and Cancel until the shared file is in its own tab and ready, then the file shows; while it is on its way the join fields don't start a second one. A join that fails or times out says why in the dialog, with Try again (a new join with the same code or link) and Cancel; Escape and the close button cancel too. `collab.leave` takes `session` to end a session by its id, whichever tab is in front. Tests: `live_joining_tests`. Numbers unchanged | | 2026-10-29 | minor | M9, #1075: `figcraft-server` gives requests that change its files (join codes, links, renames, removals, blocks, publishing with the key) a budget of 60 a minute per address and block (`Action::Admin`), on every server, with the server key and with an owner token alike; listing and looking aren't counted. Before, a managing connection with the key had no limit at all and an owner token only the message rate, so one address could ask for hundreds of 10,000-round code stretches and `links.json` writes a second (100 renames in a row: 100 served before, 60 now, then "too many changes to this server's files from your address…"; another address is unaffected). A typed or new join code is found through an index of code hashes to the files holding them, instead of opening every link's code on the server; results are the same, a copied file folder holding the same code included. The protocol is unchanged. Tests: `admin_budget_tests`. Numbers unchanged | | 2026-10-29 | minor | M9, #1415: `comment.list` (CLI, MCP `run_command`, the `figcraft://comments` resource) gives every message, the first and each reply, its `mentions`: `{name, known, you}` for each name it @mentions, by the rule the thread view highlights them with (`figcraft_doc::mentions`, which the highlighting now shares). A name resolves against the file's people and you, as the file spells it. An unknown name is reported with `known: false`. `a@b.com` mentions nobody. Filtering threads by a mentioned name and listing mentioned names in `comment.people` stay open. Numbers unchanged | diff --git a/docs/roadmap.md b/docs/roadmap.md index f3c1ba42..40f4c76e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (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); 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); 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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-10-29 · **Change:** minor (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); 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); 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M9, #1380: your name is one setting, shown where you join. Preferences calls it "Your name" (it signs your comments and is what others see in live sessions). The Join dialog shows it, editable, beside the code, as the Share popover does, and the field is empty while it is the default. A new name is kept at once, and the browser build keeps it in the page's local storage, so a reload joins as you (the other preferences still start afresh there). Changing it while in live sessions renames you in each of them: a `name` signal, which the server cleans and answers with your `PeerJoined` to everyone, and newcomers' welcomes list the new name. A rename made while joining, or before a reconnect, goes out once the host reads signals. An older server ignores it, and clearing the name keeps the session's name until you join again. Tests: `collab/src/rename_tests.rs`, `collab-net/src/rename_tests.rs` (two apps over loopback), `ui-egui/src/display_name_tests.rs`, and `figcraft-web`'s `kept` round-trip. Numbers unchanged | | 2026-10-29 | minor | A marquee from anywhere in vector edit mode (#1508): a press on another layer used to leave edit mode at once, so a drag there moved that layer; it now draws a marquee that selects the points inside it. A click there still leaves edit mode and selects what it lands on. Numbers unchanged | | 2026-10-29 | minor | M9, #1381: joining a live session shows its progress. From the click on Join (the Share popover, the home screen or the Join dialog, which an invite link opens) the Join dialog says "Joining live session…" with a spinner and Cancel until the shared file is in its own tab and ready, then the file shows; while it is on its way the join fields don't start a second one. A join that fails or times out says why in the dialog, with Try again (a new join with the same code or link) and Cancel; Escape and the close button cancel too. `collab.leave` takes `session` to end a session by its id, whichever tab is in front. Tests: `live_joining_tests`. Numbers unchanged | | 2026-10-29 | minor | M9, #1075: `figcraft-server` gives requests that change its files a budget of 60 a minute per address, with the server key and owner tokens alike, and finds join codes through an index of their hashes. Tests: `admin_budget_tests` | From 42e1086a3172175901df26aa78b61aff2247003d Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 30 Oct 2026 01:44:17 +0700 Subject: [PATCH 52/63] M5: drag a manual overlay into place on the canvas (#405) (#1555) While the interaction editor has an Open overlay action with Manual position open, the overlay frame shows dimmed and outlined where it opens, from the top-left of the layer that opens it. Dragging it sets the action's offset through proto.action.set in one gesture: the X/Y fields follow live and the drag is one undo step. --- ROADMAP.md | 3 +- crates/ui-egui/src/canvas/proto_canvas.rs | 7 + .../src/canvas/proto_canvas/overlay_ghost.rs | 283 ++++++++++++++++++ crates/ui-egui/src/panels/assets_panel.rs | 2 +- docs/prototyping-parity.md | 5 +- docs/roadmap.md | 3 +- 6 files changed, 298 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/src/canvas/proto_canvas/overlay_ghost.rs diff --git a/ROADMAP.md b/ROADMAP.md index 116d247a..f6b42d5a 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-29 · **Change:** minor (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); 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); 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-10-29 · **Change:** minor (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); 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); 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 @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M5, #405: a manual overlay can be placed on the canvas. While the interaction editor has an Open overlay action with Manual position open, the overlay frame shows dimmed and outlined where it opens, from the top-left of the layer that opens it (right below it without an offset), and dragging it moves it: the X and Y fields follow while dragging, and the drag is one undo step. Tests: `overlay_ghost` (ui-egui) | | 2026-10-29 | minor | M9, #1380: your name is one setting, shown where you join. Preferences calls it "Your name" (it signs your comments and is what others see in live sessions). The Join dialog shows it, editable, beside the code, as the Share popover does, and the field is empty while it is the default. A new name is kept at once, and the browser build keeps it in the page's local storage, so a reload joins as you (the other preferences still start afresh there). Changing it while in live sessions renames you in each of them: a `name` signal, which the server cleans and answers with your `PeerJoined` to everyone, and newcomers' welcomes list the new name. A rename made while joining, or before a reconnect, goes out once the host reads signals. An older server ignores it, and clearing the name keeps the session's name until you join again. Tests: `collab/src/rename_tests.rs`, `collab-net/src/rename_tests.rs` (two apps over loopback), `ui-egui/src/display_name_tests.rs`, and `figcraft-web`'s `kept` round-trip. Numbers unchanged | | 2026-10-29 | minor | A marquee from anywhere in vector edit mode (#1508): a press on another layer used to leave edit mode at once, so a drag there moved that layer; it now draws a marquee that selects the points inside it. A click there still leaves edit mode and selects what it lands on. Numbers unchanged | | 2026-10-29 | minor | M9, #1381: joining a live session shows its progress. From the click on Join (the Share popover, the home screen or the Join dialog, which an invite link opens) the Join dialog says "Joining live session…" with a spinner and Cancel until the shared file is in its own tab and ready, then the file shows; while it is on its way the join fields don't start a second one. A join that fails or times out says why in the dialog, with Try again (a new join with the same code or link) and Cancel; Escape and the close button cancel too. `collab.leave` takes `session` to end a session by its id, whichever tab is in front. Tests: `live_joining_tests`. Numbers unchanged | diff --git a/crates/ui-egui/src/canvas/proto_canvas.rs b/crates/ui-egui/src/canvas/proto_canvas.rs index b5d402b4..1178ed6b 100644 --- a/crates/ui-egui/src/canvas/proto_canvas.rs +++ b/crates/ui-egui/src/canvas/proto_canvas.rs @@ -10,6 +10,8 @@ use serde_json::{Value, json}; use crate::{FigApp, RightTab}; +mod overlay_ghost; + const HANDLE_R: f32 = 7.0; /// A connection being dragged out of a layer's handle. @@ -159,6 +161,7 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { let open = crate::proto_panel::editor(ctx); let hover = ctx.input(|i| i.pointer.hover_pos()); let dragging: Option = ctx.data(|d| d.get_temp(drag_id())); + overlay_ghost::draw(app, painter, doc, page); for nd in noodles(app, ctx, doc, page) { let selected = sel.contains(&nd.source); let is_open = open == Some((nd.source, nd.index)); @@ -233,6 +236,7 @@ pub fn input(app: &mut FigApp, ui: &egui::Ui, resp: &egui::Response) -> bool { if drag.is_some() { ctx.data_mut(|d| d.remove::(drag_id())); } + overlay_ghost::end(app, &ctx); return false; } let (pos, pressed, down, released) = ctx.input(|i| { @@ -243,6 +247,9 @@ pub fn input(app: &mut FigApp, ui: &egui::Ui, resp: &egui::Response) -> bool { i.pointer.button_released(egui::PointerButton::Primary), ) }); + if drag.is_none() && overlay_ghost::input(app, &ctx, pos, pos.is_some_and(|p| app.canvas.rect.contains(p)) && resp.hovered()) { + return true; + } let Ok(ds) = app.session.ds() else { return false }; let (doc, page, sel) = (ds.doc.clone(), ds.page, ds.selection.clone()); if let Some(mut d) = drag { diff --git a/crates/ui-egui/src/canvas/proto_canvas/overlay_ghost.rs b/crates/ui-egui/src/canvas/proto_canvas/overlay_ghost.rs new file mode 100644 index 00000000..21599010 --- /dev/null +++ b/crates/ui-egui/src/canvas/proto_canvas/overlay_ghost.rs @@ -0,0 +1,283 @@ +//! The ghost of a manually placed overlay: while the interaction editor has an Open overlay action +//! with Manual position open, the overlay frame shows on the canvas where it will open (its offset +//! from the top-left of the layer that opens it), and dragging the ghost moves it. The drag is one +//! undo step, and the action's X and Y fields follow it live. + +use std::sync::Arc; + +use egui::{Color32, CornerRadius, Id, Pos2, Rect, Stroke, StrokeKind, pos2}; +use figcraft_doc::proto::{Action, MAX_ACTION_DEPTH, OverlayPosition}; +use figcraft_doc::{Document, NodeId}; +use serde_json::json; + +use crate::FigApp; +use crate::proto_editor::{Slot, run_at}; + +/// The overlay thumbnail's longest side (pixels). +const GHOST_PX: f32 = 1024.0; +/// The undo step a drag makes (the label of the command it runs). +const LABEL: &str = "Edit prototype action"; + +/// The Manual overlay action being edited. +struct Manual { + slot: Slot, + action: Action, + dest: NodeId, + /// The trigger layer's top-left (page units). + origin: [f64; 2], + /// The overlay's top-left from `origin`; without one, right below the trigger layer. + offset: [f64; 2], +} + +/// A ghost being dragged: the action at the press, the page point grabbed and the last offset set. +#[derive(Clone)] +struct Drag { + slot: Slot, + action: Action, + grab: [f64; 2], + start: [f64; 2], + last: [f64; 2], +} + +fn drag_id() -> Id { + Id::unique("proto_overlay_ghost_drag") +} + +/// The first Open overlay action with Manual position in `actions` (conditional blocks too); its +/// path is left in `path`. +fn find_manual(actions: &[Action], path: &mut Vec, depth: usize) -> Option { + for (i, a) in actions.iter().enumerate() { + path.push(i); + if matches!(a, Action::OpenOverlay { position: OverlayPosition::Manual, .. }) { + return Some(a.clone()); + } + if let Action::Conditional { blocks } = a + && depth < MAX_ACTION_DEPTH + && let Some(found) = blocks.iter().enumerate().find_map(|(b, block)| { + let mut inner = vec![b]; + let found = find_manual(&block.actions, &mut inner, depth + 1)?; + path.extend(inner); + Some(found) + }) + { + return Some(found); + } + path.pop(); + } + None +} + +/// The Manual overlay of interaction `k` of `node`, if it has one. +fn manual(doc: &Document, node: NodeId, k: usize) -> Option { + let it = doc.node(node)?.interactions.get(k)?; + let mut path = Vec::new(); + let action = find_manual(&it.actions, &mut path, 0)?; + let Action::OpenOverlay { dest, offset, .. } = &action else { return None }; + let trigger = doc.world_bounds(node)?; + let (dest, offset) = (*dest, offset.unwrap_or([0.0, trigger.height()])); + Some(Manual { slot: Slot { node, k, path }, action, dest, origin: [trigger.x0, trigger.y0], offset }) +} + +/// The Manual overlay the interaction editor has open on the shown page. +fn editing(app: &FigApp, ctx: &egui::Context, doc: &Document, page: NodeId) -> Option { + let (node, k) = crate::proto_panel::editor(ctx)?; + if !super::active(app) || doc.page_of(node) != Some(page) { + return None; + } + manual(doc, node, k) +} + +/// Screen rect of page rect (`x`, `y`, `w`, `h`). +fn screen(app: &FigApp, [x, y]: [f64; 2], w: f64, h: f64) -> Rect { + Rect::from_min_max(app.to_screen(x, y), app.to_screen(x + w, y + h)) +} + +/// Where the overlay frame opens, on screen. +fn ghost_rect(app: &FigApp, doc: &Document, m: &Manual) -> Option { + let b = doc.world_bounds(m.dest)?; + Some(screen(app, [m.origin[0] + m.offset[0], m.origin[1] + m.offset[1]], b.width(), b.height())) +} + +/// The overlay frame's thumbnail and the page area it shows (effects reach past the frame), kept +/// while the frame's layers stay the same: dragging edits only the trigger layer. +#[derive(Clone)] +struct Thumb { + doc: Arc, + dest: NodeId, + tex: Option, +} + +fn thumbnail(ctx: &egui::Context, doc: &Arc, dest: NodeId) -> Option { + let id = Id::unique("proto_overlay_ghost_thumb"); + let kept: Option = ctx.data(|d| d.get_temp(id)); + let tex = match kept { + Some(t) if t.dest == dest && (Arc::ptr_eq(&t.doc, doc) || crate::assets_panel::same_layers(&t.doc, doc, dest)) => t.tex, + _ => crate::assets_panel::thumbnail(ctx, doc, dest, GHOST_PX), + }; + ctx.data_mut(|d| d.insert_temp(id, Thumb { doc: doc.clone(), dest, tex: tex.clone() })); + tex +} + +/// Draw the ghost of the Manual overlay being edited: the frame, dimmed, outlined in the accent. +pub(super) fn draw(app: &FigApp, painter: &egui::Painter, doc: &Arc, page: NodeId) { + let ctx = painter.ctx(); + let Some(m) = editing(app, ctx, doc, page) else { return }; + let Some(r) = ghost_rect(app, doc, &m) else { return }; + let accent = crate::theme::canvas_accent(ctx); + let art = figcraft_render::export_bounds(doc, m.dest).zip(doc.world_bounds(m.dest)); + match (thumbnail(ctx, doc, m.dest), art) { + (Some(tex), Some((e, b))) => { + let at = [m.origin[0] + m.offset[0] + e.x0 - b.x0, m.origin[1] + m.offset[1] + e.y0 - b.y0]; + let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); + painter.image(tex.id(), screen(app, at, e.width(), e.height()), uv, Color32::WHITE.gamma_multiply(0.6)); + } + _ => { + painter.rect_filled(r, CornerRadius::ZERO, accent.gamma_multiply(0.12)); + } + } + painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(1.5, accent), StrokeKind::Outside); +} + +/// Move the dragged ghost so the grabbed point is under `p`. +fn drag_to(app: &mut FigApp, ctx: &egui::Context, mut d: Drag, p: Pos2) { + let at = app.to_page(p); + let o = [(d.start[0] + at.x - d.grab[0]).round(), (d.start[1] + at.y - d.grab[1]).round()].map(|v| v.clamp(-100_000.0, 100_000.0)); + if o != d.last { + let mut action = d.action.clone(); + if let Action::OpenOverlay { offset, .. } = &mut action { + *offset = Some(o); + } + run_at(app, &d.slot, "proto.action.set", json!({ "action": action })); + d.last = o; + } + ctx.data_mut(|m| m.insert_temp(drag_id(), d)); +} + +/// End a drag under way, as one undo step. +pub(super) fn end(app: &mut FigApp, ctx: &egui::Context) { + if ctx.data(|m| m.get_temp::(drag_id())).is_some() { + ctx.data_mut(|m| m.remove::(drag_id())); + let _ = app.session.commit_interaction(); + } +} + +/// Canvas input for the ghost: a press on it starts a drag, which follows the pointer until the +/// button is released. Returns true while it has the gesture. +pub(super) fn input(app: &mut FigApp, ctx: &egui::Context, pos: Option, hovered: bool) -> bool { + let (pressed, down) = ctx.input(|i| (i.pointer.button_pressed(egui::PointerButton::Primary), i.pointer.primary_down())); + if let Some(d) = ctx.data(|m| m.get_temp::(drag_id())) { + ctx.set_cursor_icon(egui::CursorIcon::Grabbing); + ctx.request_repaint(); + match pos { + Some(p) if down => drag_to(app, ctx, d, p), + _ => end(app, ctx), + } + return true; + } + let Some(p) = pos.filter(|_| hovered) else { return false }; + let Ok(ds) = app.session.ds() else { return false }; + let (doc, page) = (ds.doc.clone(), ds.page); + let Some(m) = editing(app, ctx, &doc, page).filter(|m| ghost_rect(app, &doc, m).is_some_and(|r| r.contains(p))) else { return false }; + ctx.set_cursor_icon(egui::CursorIcon::Grab); + if !pressed || app.session.begin_interaction(LABEL).is_err() { + return false; + } + let at = app.to_page(p); + let d = Drag { slot: m.slot, action: m.action, grab: [at.x, at.y], start: m.offset, last: m.offset }; + ctx.data_mut(|m| m.insert_temp(drag_id(), d)); + true +} + +#[cfg(test)] +mod tests { + use egui::{Event, PointerButton, vec2}; + use figcraft_doc::NodeId; + use figcraft_doc::proto::{Action, OverlayAnchor, OverlayPosition}; + use figcraft_engine::Session; + use serde_json::json; + + use super::{find_manual, ghost_rect, manual}; + use crate::RightTab; + use crate::present_tests::{Ui, id}; + + fn overlay(dest: NodeId, position: OverlayPosition, offset: Option<[f64; 2]>) -> Action { + Action::OpenOverlay { dest, position, close_on_outside: true, background: None, offset, anchor: OverlayAnchor::Screen } + } + + /// A button in a frame opening frame `b` as a Manual overlay 10, 20 from the button's corner, + /// with the interaction editor open on it. + fn scene() -> (Ui, NodeId, NodeId) { + let mut s = Session::new(); + let mut ex = |c: &str, p: serde_json::Value| s.execute(c, &p).unwrap(); + ex("frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 300})); + let btn = id(&ex("shape.rectangle", json!({"x": 40, "y": 30, "width": 80, "height": 30}))); + let b = id(&ex("frame.create", json!({"x": 600, "y": 0, "width": 160, "height": 100}))); + ex("proto.interaction.add", json!({"ids": [btn], "dest": b})); + ex("proto.action.set", json!({"ids": [btn], "index": 0, "path": [0], "action": overlay(b, OverlayPosition::Manual, Some([10.0, 20.0]))})); + ex("select.set", json!({"ids": [btn]})); + let mut u = Ui::new(s); + u.app.ui.right_tab = RightTab::Prototype; + u.run("view.zoomFit", json!({})); + crate::proto_panel::open_editor(&u.ctx, Some((btn, 0))); + u.frames(2, Vec::new); + (u, btn, b) + } + + fn offset(u: &Ui, btn: NodeId) -> Option<[f64; 2]> { + let doc = u.app.session.doc().unwrap(); + match doc.node(btn).unwrap().interactions[0].actions.first() { + Some(Action::OpenOverlay { offset, .. }) => *offset, + _ => None, + } + } + + #[test] + fn dragging_the_ghost_moves_the_overlay_by_the_dragged_amount_in_one_undo_step() { + let (mut u, btn, _) = scene(); + let doc = u.app.session.doc().unwrap().clone(); + let r = ghost_rect(&u.app, &doc, &manual(&doc, btn, 0).unwrap()).unwrap(); + let (from, to) = (r.center(), r.center() + vec2(60.0, 45.0)); + let moved = [u.app.to_page(to).x - u.app.to_page(from).x, u.app.to_page(to).y - u.app.to_page(from).y]; + let steps = u.app.session.undo_labels(100).len(); + u.button(from, true); + for k in 1..=3 { + u.frame(vec![Event::PointerMoved(from + vec2(20.0, 15.0) * k as f32)]); + } + assert_eq!(offset(&u, btn), Some([10.0 + moved[0].round(), 20.0 + moved[1].round()]), "the X/Y follow while dragging"); + u.frame(vec![Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Default::default() }]); + u.frames(2, Vec::new); + assert_eq!(offset(&u, btn), Some([10.0 + moved[0].round(), 20.0 + moved[1].round()])); + assert_eq!(u.app.session.undo_labels(100).len(), steps + 1, "one undo step"); + u.app.session.undo().unwrap(); + assert_eq!(offset(&u, btn), Some([10.0, 20.0])); + } + + #[test] + fn without_an_offset_the_ghost_sits_right_below_the_trigger() { + let mut s = Session::new(); + let btn = id(&s.execute("shape.rectangle", &json!({"x": 5, "y": 7, "width": 50, "height": 20})).unwrap()); + let b = id(&s.execute("frame.create", &json!({"x": 300, "y": 0, "width": 100, "height": 80})).unwrap()); + s.execute("proto.interaction.add", &json!({"ids": [btn], "dest": b})).unwrap(); + s.execute("proto.action.set", &json!({"ids": [btn], "index": 0, "path": [0], "action": overlay(b, OverlayPosition::Manual, None)})).unwrap(); + let m = manual(&s.doc().unwrap().clone(), btn, 0).unwrap(); + assert_eq!((m.origin, m.offset, m.dest), ([5.0, 7.0], [0.0, 20.0], b)); + } + + #[test] + fn only_a_manual_overlay_has_a_ghost_and_nested_ones_are_found() { + let d = NodeId(9); + let centred = overlay(d, OverlayPosition::Center, None); + let mut path = Vec::new(); + assert!(find_manual(std::slice::from_ref(¢red), &mut path, 0).is_none()); + assert!(path.is_empty()); + let nested = Action::Conditional { + blocks: vec![ + figcraft_doc::proto::ConditionalBlock { condition: None, actions: vec![centred.clone()] }, + figcraft_doc::proto::ConditionalBlock { condition: None, actions: vec![Action::Back, overlay(d, OverlayPosition::Manual, None)] }, + ], + }; + let found = find_manual(&[centred, nested], &mut path, 0); + assert!(matches!(found, Some(Action::OpenOverlay { position: OverlayPosition::Manual, .. }))); + assert_eq!(path, vec![1, 1, 1]); + } +} diff --git a/crates/ui-egui/src/panels/assets_panel.rs b/crates/ui-egui/src/panels/assets_panel.rs index db0cc0f7..4fe5566d 100644 --- a/crates/ui-egui/src/panels/assets_panel.rs +++ b/crates/ui-egui/src/panels/assets_panel.rs @@ -171,7 +171,7 @@ fn drag_id() -> Id { /// Whether component `id` has the same layers in two versions of a document (layers an edit /// didn't touch stay shared between the versions, so this compares pointers, not contents). -fn same_layers(a: &Document, b: &Document, id: NodeId) -> bool { +pub(crate) fn same_layers(a: &Document, b: &Document, id: NodeId) -> bool { let mut stack = vec![id]; let mut seen = 0; while let Some(i) = stack.pop() { diff --git a/docs/prototyping-parity.md b/docs/prototyping-parity.md index f8241097..1521437c 100644 --- a/docs/prototyping-parity.md +++ b/docs/prototyping-parity.md @@ -1,6 +1,6 @@ # Prototyping parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-29 · **Change:** patch (Open link notices now cover mail links, and skipping them covers only links the viewer fired (#1096); overlay positions relative to the layer that opens them; the Triggers row no longer lists delays on mouse triggers as missing; top-level frames bigger than the device's screen scroll in the player; Change to, set variable and set variable mode lay out what they change in the player, as the canvas does; presentation tabs, one per page of a file; a Present tab with device and scale controls (#537); core editor leftovers: delays on mouse triggers and manual overlay offsets; controller buttons on key/gamepad triggers, widgets that react in prototypes, `proto.evaluate` and the mode lookup rules pinned) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-30 · **Change:** patch (a manual overlay's position can be dragged on the canvas (#405); Open link notices now cover mail links, and skipping them covers only links the viewer fired (#1096); overlay positions relative to the layer that opens them; the Triggers row no longer lists delays on mouse triggers as missing; top-level frames bigger than the device's screen scroll in the player; Change to, set variable and set variable mode lay out what they change in the player, as the canvas does; presentation tabs, one per page of a file; a Present tab with device and scale controls (#537); core editor leftovers: delays on mouse triggers and manual overlay offsets; controller buttons on key/gamepad triggers, widgets that react in prototypes, `proto.evaluate` and the mode lookup rules pinned) · **Target:** Figma Design (desktop app 126.10.7) **Breadth ~92%, ready ~74% (estimated).** Every Figma trigger except media, every action except media control, every transition and easing exist; key/gamepad triggers take controller buttons, @@ -50,7 +50,7 @@ tool; opt-in), **silent** (the documentation doesn't say; the case pins the curr | Scroll to with an offset: the layer stops that far from the top-left (room for a fixed header) | **added** | `offset: [x, y]` on the action, X / Y fields in the interaction editor; `scroll_to_brings_the_layer_to_the_top_less_its_offset` (nested scrolling frames: the innermost keeps the offset; silent) | | Open overlay: positions, background dimming, close on a click outside (on by default, can be turned off) | pass | `a_click_outside_an_overlay_closes_it_unless_turned_off`, `overlay_positions` | | Overlays stack; swap overlay replaces the top one; close overlay closes it | pass | `overlays_stack_swap_close_and_go_with_navigation` | -| Manual overlay position relative to the hotspot | **added** | `offset: [x, y]` on the action, from the top-left of the layer that opens it (X / Y fields in the interaction editor; without one, below that layer); `present_delay_tests` | +| Manual overlay position relative to the hotspot | **added** | `offset: [x, y]` on the action, from the top-left of the layer that opens it (X / Y fields in the interaction editor, or drag the overlay's ghost on the canvas while the action is open, one undo step; without one, below that layer); `present_delay_tests`, `overlay_ghost` tests | | Overlay position presets relative to the layer that opens it, inside or outside (not in Figma: [forum request](https://forum.figma.com/suggest-a-feature-11/set-overlay-position-relative-to-element-not-to-page-35366)) | **extra** | `anchor: triggerInside \| triggerOutside` on the open overlay action; Relative to (Whole screen / Trigger layer) and Inside / Outside in the interaction editor. Outside: top presets above the layer, bottom presets below it, edges lined up (a dropdown is Bottom left + Outside); Center centres it on the layer. No flipping at the screen edge. `.fig` export writes a manual offset; `proto_overlay_anchor_tests`, `present_overlay_anchor_tests`, MCP `overlay_anchor` | | Delay on mouse enter / leave / down / up | **added** | `delayMs` on the interaction, a Delay field in the editor; fires once after the delay while its screen or overlay still shows (a pointer that leaves early: silent, it still fires); `mouse_triggers_wait_out_their_delay` | | Open link | pass | always a new tab or browser window (opening in place would leave the prototype) | @@ -105,6 +105,7 @@ tool; opt-in), **silent** (the documentation doesn't say; the case pins the curr | Date | Change | Summary | |---|---|---| +| 2026-10-30 | patch | A manual overlay can be placed on the canvas (#405): while the interaction editor has an Open overlay action with Manual position open, the overlay frame shows dimmed, outlined, where it opens from the top-left of the layer that opens it (right below that layer without an offset), and dragging it moves it there. The X and Y fields follow while dragging, and the drag is one undo step (`crates/ui-egui/src/canvas/proto_canvas/overlay_ghost.rs`). Numbers unchanged | | 2026-10-29 | patch | The Open link notice covers mail links, and skipping it covers only links the viewer fired: before, a mail link opened the mail app at once, also from an "After delay" trigger, and while the notice was skipped any trigger opened any number of web links. Now every openable link waits behind the notice; with it skipped, a link the viewer's own click, press, drag or key fired opens at once, one per frame, while a delay, the pointer passing over a layer or a timed mouse trigger still shows the notice, which then offers no "Don't show this again". Nothing opens while a notice shows, and a press under way when it appears ends there: before, its release could still open a link or navigate behind the notice. The mail notice says the link opens the mail app to write a message (#1096, `crates/ui-egui/src/present_links_tests.rs`). Numbers unchanged | | 2026-10-24 | patch | The Triggers row's Missing column no longer lists delays on mouse enter, leave, down and up, which landed on 2026-10-13 (`crates/engine/src/proto_delay_tests.rs`, `crates/ui-egui/src/present/delay_tests.rs`). Numbers unchanged | | 2026-10-24 | patch | Open link to a website first shows a notice that it leaves the prototype, with the link, Open link, Cancel and "Don't show this again", which the desktop app saves with its preferences and the web build keeps until the page reloads (`proto.linkNotice` turns the notice back on); a second link to a website that fires while the notice shows is dropped, and a mail link still opens. Before, the player opened the link at once, also from an After delay trigger. Numbers unchanged | diff --git a/docs/roadmap.md b/docs/roadmap.md index 40f4c76e..bac54aae 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (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); 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); 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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-10-29 · **Change:** minor (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); 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); 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M5, #405: a manual overlay's ghost shows on the canvas while its Open overlay action is open in the interaction editor, and dragging it sets the action's offset from the layer that opens it (X and Y follow, one undo step). Tests: `overlay_ghost` (ui-egui) | | 2026-10-29 | minor | M9, #1380: your name is one setting, shown where you join. Preferences calls it "Your name" (it signs your comments and is what others see in live sessions). The Join dialog shows it, editable, beside the code, as the Share popover does, and the field is empty while it is the default. A new name is kept at once, and the browser build keeps it in the page's local storage, so a reload joins as you (the other preferences still start afresh there). Changing it while in live sessions renames you in each of them: a `name` signal, which the server cleans and answers with your `PeerJoined` to everyone, and newcomers' welcomes list the new name. A rename made while joining, or before a reconnect, goes out once the host reads signals. An older server ignores it, and clearing the name keeps the session's name until you join again. Tests: `collab/src/rename_tests.rs`, `collab-net/src/rename_tests.rs` (two apps over loopback), `ui-egui/src/display_name_tests.rs`, and `figcraft-web`'s `kept` round-trip. Numbers unchanged | | 2026-10-29 | minor | A marquee from anywhere in vector edit mode (#1508): a press on another layer used to leave edit mode at once, so a drag there moved that layer; it now draws a marquee that selects the points inside it. A click there still leaves edit mode and selects what it lands on. Numbers unchanged | | 2026-10-29 | minor | M9, #1381: joining a live session shows its progress. From the click on Join (the Share popover, the home screen or the Join dialog, which an invite link opens) the Join dialog says "Joining live session…" with a spinner and Cancel until the shared file is in its own tab and ready, then the file shows; while it is on its way the join fields don't start a second one. A join that fails or times out says why in the dialog, with Try again (a new join with the same code or link) and Cancel; Escape and the close button cancel too. `collab.leave` takes `session` to end a session by its id, whichever tab is in front. Tests: `live_joining_tests`. Numbers unchanged | From 562aae760629223221891fa32065d50bd9ff6cfa Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:57:26 +0100 Subject: [PATCH 53/63] Reject non-finite hsl()/hsla() components in parse_css Found while testing crates/color/src/lib.rs:~173-180 (parse_css, hsl/hsla arm) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/color/src/lib.rs | 6 +++++- crates/color/src/tests.rs | 7 +++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 42fb6a69..c2c0d06e 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -180,6 +180,9 @@ impl Color { let h = parts.first()?.trim_end_matches("deg").parse::().ok()?; let s = parts.get(1)?.trim_end_matches('%').parse::().ok()? / 100.0; let l = parts.get(2)?.trim_end_matches('%').parse::().ok()? / 100.0; + if !(h.is_finite() && s.is_finite() && l.is_finite()) { + return None; + } let alpha = match parts.get(3) { Some(a) => parse_alpha(a)?, None => 1.0, @@ -286,7 +289,8 @@ fn css_hue(p: &str) -> Option { } fn parse_alpha(a: &str) -> Option { - if let Some(pc) = a.strip_suffix('%') { pc.parse::().ok().map(|v| v / 100.0) } else { a.parse::().ok() } + let v = if let Some(pc) = a.strip_suffix('%') { pc.parse::().ok().map(|v| v / 100.0) } else { a.parse::().ok() }?; + v.is_finite().then_some(v) } /// One colour stop of a gradient. diff --git a/crates/color/src/tests.rs b/crates/color/src/tests.rs index 2b628b71..03e4d167 100644 --- a/crates/color/src/tests.rs +++ b/crates/color/src/tests.rs @@ -141,3 +141,10 @@ fn to_css_round_trips_every_alpha() { assert_eq!(Color::parse_css(&c.to_css()).unwrap().to_rgba8(), c.to_rgba8(), "alpha {a}: {}", c.to_css()); } } + +#[test] +fn css_hsl_rejects_non_finite() { + for s in ["hsl(nan,50%,50%)", "hsl(inf,50%,50%)", "hsl(0,inf%,50%)", "hsl(0,50%,infinity%)", "hsla(120,50%,50%,nan)"] { + assert_eq!(Color::parse_css(s), None, "{s}"); + } +} From 132abcc6728697d72adb0cf9f07d044575cf9f63 Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 30 Oct 2026 02:03:27 +0700 Subject: [PATCH 54/63] M8: show in the app where an attached AI agent is working (#1416) (#1556) Session::execute_as records the agent's presence: its name, the file, and the innermost layers its last edit changed or the selection it set, for 8 seconds after its last command. The app shows it as an avatar in the people row and an outline with a name tag on the canvas, in its own colour. Display only: no undo step, never saved, never synced to a live session. --- ROADMAP.md | 3 +- crates/engine/src/agent_presence_tests.rs | 84 ++++++++++++++++++ crates/engine/src/lib.rs | 5 ++ crates/engine/src/session/exec.rs | 6 +- crates/engine/src/session/new.rs | 1 + crates/engine/src/sharing.rs | 1 + crates/engine/src/sharing/agent.rs | 77 +++++++++++++++++ crates/ui-egui/src/canvas.rs | 1 + crates/ui-egui/src/live.rs | 51 +++++++---- crates/ui-egui/src/live/agent.rs | 101 ++++++++++++++++++++++ crates/ui-egui/src/theme.rs | 4 + docs/collaboration-parity.md | 4 +- docs/mcp.md | 11 ++- docs/roadmap.md | 3 +- 14 files changed, 332 insertions(+), 20 deletions(-) create mode 100644 crates/engine/src/agent_presence_tests.rs create mode 100644 crates/engine/src/sharing/agent.rs create mode 100644 crates/ui-egui/src/live/agent.rs diff --git a/ROADMAP.md b/ROADMAP.md index f6b42d5a..9749f11f 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-29 · **Change:** minor (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); 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); 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-10-29 · **Change:** minor (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); 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); 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 @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M8, #1416: an AI agent attached over MCP shows where it works, as a person in a live session does: its avatar in its own colour in the people row, named as its comments are signed ("Claude (agent)"), and an outline with its name around the layers its last edit changed or it last selected. It goes 8 seconds after its last command and shows only in the file it works in. Display only (no undo step, not saved, never synced to a live session); the engine records it in `Session::execute_as` (`figcraft_engine::agent`). Tests: `agent_presence_tests` (engine), `live::agent` (ui-egui) | | 2026-10-30 | minor | M5, #405: a manual overlay can be placed on the canvas. While the interaction editor has an Open overlay action with Manual position open, the overlay frame shows dimmed and outlined where it opens, from the top-left of the layer that opens it (right below it without an offset), and dragging it moves it: the X and Y fields follow while dragging, and the drag is one undo step. Tests: `overlay_ghost` (ui-egui) | | 2026-10-29 | minor | M9, #1380: your name is one setting, shown where you join. Preferences calls it "Your name" (it signs your comments and is what others see in live sessions). The Join dialog shows it, editable, beside the code, as the Share popover does, and the field is empty while it is the default. A new name is kept at once, and the browser build keeps it in the page's local storage, so a reload joins as you (the other preferences still start afresh there). Changing it while in live sessions renames you in each of them: a `name` signal, which the server cleans and answers with your `PeerJoined` to everyone, and newcomers' welcomes list the new name. A rename made while joining, or before a reconnect, goes out once the host reads signals. An older server ignores it, and clearing the name keeps the session's name until you join again. Tests: `collab/src/rename_tests.rs`, `collab-net/src/rename_tests.rs` (two apps over loopback), `ui-egui/src/display_name_tests.rs`, and `figcraft-web`'s `kept` round-trip. Numbers unchanged | | 2026-10-29 | minor | A marquee from anywhere in vector edit mode (#1508): a press on another layer used to leave edit mode at once, so a drag there moved that layer; it now draws a marquee that selects the points inside it. A click there still leaves edit mode and selects what it lands on. Numbers unchanged | diff --git a/crates/engine/src/agent_presence_tests.rs b/crates/engine/src/agent_presence_tests.rs new file mode 100644 index 00000000..47a8671e --- /dev/null +++ b/crates/engine/src/agent_presence_tests.rs @@ -0,0 +1,84 @@ +//! An attached AI agent's presence (#1416): its commands set it, on what they edited or selected; +//! it goes a short while after its last command; the user's commands never make one. + +use std::time::Duration; + +use figcraft_doc::NodeId; +use serde_json::{Value, json}; + +use crate::Session; +use crate::agent::LINGER; + +fn id(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn rect(s: &mut Session, agent: Option<&str>, x: i32) -> NodeId { + id(&s.execute_as(agent, "shape.rectangle", &json!({"x": x, "y": 0, "width": 40, "height": 40})).unwrap()) +} + +#[test] +fn an_agents_edit_shows_it_on_the_layers_it_changed() { + let mut s = Session::new(); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 200})).unwrap()); + let r = id(&s.execute_as(Some("Claude"), "shape.rectangle", &json!({"parent": f, "x": 10, "y": 10, "width": 40, "height": 40})).unwrap()); + let p = s.agent_presence.clone().expect("the agent is at work"); + assert_eq!(p.name, "Claude (agent)"); + assert_eq!(p.doc, s.ds().unwrap().uid); + assert_eq!(p.layers, vec![r], "the new layer, not the frame whose children changed with it"); + assert_eq!(s.agent, None, "the command ran as the agent only"); +} + +#[test] +fn an_agents_selection_shows_it_there_and_a_look_keeps_it() { + let mut s = Session::new(); + let (a, b) = (rect(&mut s, None, 0), rect(&mut s, None, 100)); + s.execute_as(Some(""), "select.set", &json!({"ids": [a, b]})).unwrap(); + let p = s.agent_presence.clone().unwrap(); + assert_eq!((p.name.as_str(), p.layers.clone()), ("AI agent", vec![a, b])); + // A command that changes nothing keeps where it works and when it was last seen moves on. + s.execute_as(Some(""), "view.zoomFit", &json!({})).unwrap(); + let again = s.agent_presence.clone().unwrap(); + assert_eq!(again.layers, vec![a, b]); + assert!(again.at >= p.at); + // So does a failed one. + assert!(s.execute_as(Some(""), "node.set", &json!({"ids": [999_999], "props": {}})).is_err()); + assert_eq!(s.agent_presence.as_ref().unwrap().layers, vec![a, b]); +} + +#[test] +fn the_presence_goes_a_short_while_after_the_last_command() { + let mut s = Session::new(); + rect(&mut s, Some("Claude"), 0); + let at = s.agent_presence.as_ref().unwrap().at; + assert!(s.agent_presence_at(at).is_some()); + assert!(s.agent_presence_at(at + LINGER - Duration::from_millis(1)).is_some()); + assert_eq!(s.agent_presence.as_ref().unwrap().left(at + Duration::from_secs(2)), LINGER - Duration::from_secs(2)); + assert!(s.agent_presence_at(at + LINGER).is_none(), "gone after the linger"); +} + +#[test] +fn the_users_commands_never_make_one() { + let mut s = Session::new(); + let a = rect(&mut s, None, 0); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + s.execute("node.set", &json!({"ids": [a], "props": {"x": 50}})).unwrap(); + assert!(s.agent_presence.is_none()); + // An agent's name on one file: the user's later edits elsewhere don't move it. + rect(&mut s, Some("Claude"), 200); + let before = s.agent_presence.clone(); + s.execute("node.set", &json!({"ids": [a], "props": {"x": 80}})).unwrap(); + assert_eq!(s.agent_presence, before); +} + +#[test] +fn another_file_starts_afresh() { + let mut s = Session::new(); + let a = rect(&mut s, Some("Claude"), 0); + assert_eq!(s.agent_presence.as_ref().unwrap().layers, vec![a]); + s.execute("file.new", &json!({})).unwrap(); + s.execute_as(Some("Claude"), "view.zoomFit", &json!({})).unwrap(); + let p = s.agent_presence.clone().unwrap(); + assert_eq!(p.doc, s.ds().unwrap().uid); + assert!(p.layers.is_empty(), "the old file's layers don't follow it"); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7d3164f1..2d9bf932 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -77,6 +77,7 @@ pub use selection::rename; pub use selection::selection_colors; pub use selection::selection_reorder; pub use selection::selection_spacing; +pub use sharing::agent; pub use sharing::background; pub use sharing::branches; pub use sharing::collab; @@ -153,6 +154,8 @@ pub struct Session { /// The AI agent the running command is for ([`Session::execute_as`]), as comments are signed /// (`"Claude (agent)"`); `None` for the user. pub agent: Option, + /// Where an attached AI agent last worked ([`Session::agent_presence_at`]): display only. + pub agent_presence: Option, } impl Default for Session { @@ -164,6 +167,8 @@ impl Default for Session { #[cfg(test)] mod a11y_keys_tests; #[cfg(test)] +mod agent_presence_tests; +#[cfg(test)] mod al_behaviour_tests; #[cfg(test)] mod al_content_align_tests; diff --git a/crates/engine/src/session/exec.rs b/crates/engine/src/session/exec.rs index 17d191cc..faabb84b 100644 --- a/crates/engine/src/session/exec.rs +++ b/crates/engine/src/session/exec.rs @@ -47,10 +47,14 @@ impl Session { /// [`Session::execute`] for an AI agent named `agent` (the client's name; empty when it gave /// none), or for the user (`None`): the comments it makes are signed with the agent's name and - /// marked as an agent's. + /// marked as an agent's, and the app shows where it works ([`Session::agent_presence_at`]). pub fn execute_as(&mut self, agent: Option<&str>, id: &str, params: &Value) -> Result { let was = std::mem::replace(&mut self.agent, agent.map(cmd::comment::agent_author)); + let before = agent.and_then(|_| self.agent_before()); let r = self.execute(id, params); + if agent.is_some() { + self.note_agent(before); + } self.agent = was; r } diff --git a/crates/engine/src/session/new.rs b/crates/engine/src/session/new.rs index 6e86c7c7..2e7c4374 100644 --- a/crates/engine/src/session/new.rs +++ b/crates/engine/src/session/new.rs @@ -31,6 +31,7 @@ impl Session { events: Arc::new(events::EventLog::default()), pending_events: Vec::new(), agent: None, + agent_presence: None, } } diff --git a/crates/engine/src/sharing.rs b/crates/engine/src/sharing.rs index 27d1e560..5d516537 100644 --- a/crates/engine/src/sharing.rs +++ b/crates/engine/src/sharing.rs @@ -1,3 +1,4 @@ +pub mod agent; pub mod background; pub mod branches; pub mod collab; diff --git a/crates/engine/src/sharing/agent.rs b/crates/engine/src/sharing/agent.rs new file mode 100644 index 00000000..cdf9d9ab --- /dev/null +++ b/crates/engine/src/sharing/agent.rs @@ -0,0 +1,77 @@ +//! Where an AI agent attached over MCP works, for the app to show like a person in a live +//! session: its name and the layers it last edited or selected, until a short while after its last +//! command ([`Session::execute_as`]). Display only: it is no undo step, it is never saved, and it is +//! not presence a live session sends (the agent shows there only if it joins one itself). + +use std::collections::HashSet; +use std::sync::Arc; +use std::time::Duration; + +use figcraft_doc::{Document, NodeId}; +use web_time::Instant; + +use crate::{Session, changed_nodes}; + +/// How long an agent's presence shows after its last command. +pub const LINGER: Duration = Duration::from_secs(8); +/// Most layers an agent's presence names. +pub const MAX_LAYERS: usize = 500; + +/// An attached AI agent at work. +#[derive(Clone, Debug, PartialEq)] +pub struct AgentPresence { + /// As its comments are signed ("Claude (agent)"). + pub name: String, + /// The file it last worked in (`DocState::uid`). + pub doc: u64, + /// The layers its last edit changed (the innermost), or that it last selected. + pub layers: Vec, + /// When its last command ran. + pub at: Instant, +} + +impl AgentPresence { + /// How much longer it shows at `now` (zero: gone). + pub fn left(&self, now: Instant) -> Duration { + LINGER.saturating_sub(now.saturating_duration_since(self.at)) + } +} + +/// The document and selection a command started from: (file, document, selection). +pub(crate) type Before = (u64, Arc, Vec); + +/// The layers a command worked on, going from `before` to `after`: the innermost layers it edited +/// (a parent changes with its children's list), else the selection it changed to; `None` when it +/// did neither. +fn touched(before: &Document, after: &Document, sel_before: &[NodeId], sel_after: &[NodeId]) -> Option> { + let changed: Vec = if std::ptr::eq(before, after) { Vec::new() } else { changed_nodes(after, before) }; + let edited: Vec<&figcraft_doc::Node> = changed.iter().filter_map(|i| after.node(*i)).filter(|n| !n.is_page()).collect(); + if !edited.is_empty() { + let parents: HashSet = edited.iter().filter_map(|n| n.parent).collect(); + return Some(edited.iter().map(|n| n.id).filter(|i| !parents.contains(i)).take(MAX_LAYERS).collect()); + } + (sel_before != sel_after).then(|| sel_after.iter().copied().take(MAX_LAYERS).collect()) +} + +impl Session { + /// The document and selection before a command, for [`Session::note_agent`]. + pub(crate) fn agent_before(&self) -> Option { + self.ds().ok().map(|d| (d.uid, d.doc.clone(), d.selection.clone())) + } + + /// The running command is the agent's: it is at work, on what it just edited or selected + /// (else on what it worked on before in the same file). + pub(crate) fn note_agent(&mut self, before: Option) { + let (Some(name), Ok(ds)) = (self.agent.clone(), self.ds()) else { return }; + let uid = ds.uid; + let now = before.filter(|b| b.0 == uid).and_then(|(_, doc, sel)| touched(&doc, &ds.doc, &sel, &ds.selection)); + let kept = self.agent_presence.take().filter(|p| p.doc == uid && p.name == name).map(|p| p.layers); + let layers = now.or(kept).unwrap_or_default(); + self.agent_presence = Some(AgentPresence { name, doc: uid, layers, at: Instant::now() }); + } + + /// The agent at work at `now`, if one ran a command less than [`LINGER`] before. + pub fn agent_presence_at(&self, now: Instant) -> Option<&AgentPresence> { + self.agent_presence.as_ref().filter(|p| !p.left(now).is_zero()) + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a7e41c34..1e7e535e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -298,6 +298,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { crate::comments::draw(app, &painter); crate::live_text::draw(app, &painter); crate::live::draw(app, &painter, &ctx); + crate::live::agent::draw(app, &painter, &ctx); crate::live::chat_box(app, &ctx); crate::live::spotlight_banner(app, &ctx, rect); crate::comments::popovers(app, &ctx); diff --git a/crates/ui-egui/src/live.rs b/crates/ui-egui/src/live.rs index 31e2f066..2abcbe9a 100644 --- a/crates/ui-egui/src/live.rs +++ b/crates/ui-egui/src/live.rs @@ -28,6 +28,8 @@ use crate::right::{menu_item, menu_item_with, menu_popup}; use crate::theme::{ACCENT, Tokens, regular, semibold}; use crate::widgets; +pub mod agent; + /// In the browser: what joining needs (a page can't host, and https pages can't reach `ws://`). const BROWSER_NOTE: &str = tr_noop( "In the browser you can join sessions the desktop app hosts and files on a FigCraft server. A page opened over https reaches other computers only through wss:// links (a server behind TLS).", @@ -471,8 +473,9 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { let (in_session, spotlighting) = (live.status == Status::Live, live.spotlighting); let can_remove = figcraft_engine::live::can_remove_people(&app.session); let mut remove = None; - // Room for the avatars after yours and the chevron. - let fit = (((area.width() - 2.0 * R - 18.0) / STEP).floor() as usize).max(1); + let agent = agent::shown(app, ui.ctx()).map(|p| p.name.clone()); + // Room for the avatars after yours (and the agent's) and the chevron. + let fit = (((area.width() - 2.0 * R - 18.0 - STEP * f32::from(u8::from(agent.is_some()))) / STEP).floor() as usize).max(1); let (show, more) = if peers.len() > fit { (fit - 1, peers.len() - (fit - 1)) } else { (peers.len(), 0) }; let mut x = area.min.x + R + 2.0; let ring = |ui: &Ui, c: Pos2| ui.painter().circle_stroke(c, R + 1.0, Stroke::new(2.0, t.panel)); @@ -524,6 +527,10 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { ui.painter().circle_filled(c, R, t.pressed); ui.painter().text(c, Align2::CENTER_CENTER, format!("+{more}"), semibold(10.0), t.text); } + if let Some(name) = &agent { + x += STEP; + agent::avatar(ui, pos2(x, cy), R, name); + } let chev = Rect::from_center_size(pos2(x + R + 10.0, cy), vec2(16.0, 24.0)); let cresp = ui.interact(chev, ui.scope_id().with("live_people"), Sense::click()); a11y::menu_button(&cresp, tr("People in this file"), a11y::menu_open(&cresp)); @@ -531,14 +538,38 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { ui.painter().rect_filled(chev, CornerRadius::same(4), t.hover); } icons::paint(ui.painter(), Rect::from_center_size(chev.center(), vec2(10.0, 10.0)), Icon::Chevron, t.icon_secondary); - menu_popup(&cresp).width(220.0).show(|ui| { + clicked = people_menu(app, &cresp, (&me_name, me_fill), &peers, follow).or(clicked); + if let Some(id) = clicked { + let params = if follow == Some(id) { json!({}) } else { json!({"client": id}) }; + app.run("collab.follow", params); + } + if spotlight { + app.run("collab.spotlight", json!({"on": !spotlighting})); + } + // A server is asked without the window waiting for it. + if let Some(id) = remove { + app.run_background(&ui.ctx().clone(), "collab.removePerson", json!({"client": id}), Box::new(|_, _, _| {})); + } +} + +/// The people row's chevron menu: you and everyone else; the person clicked (to follow). +fn people_menu( + app: &FigApp, + cresp: &egui::Response, + (me_name, me_fill): (&str, Color32), + peers: &[(u32, String, Color32)], + follow: Option, +) -> Option { + let t = Tokens::get(&cresp.ctx); + let mut clicked = None; + menu_popup(cresp).width(220.0).show(|ui| { let row = |ui: &mut Ui, label: &str, fill: Color32, check: bool| { menu_item_with(ui, label, if check { tr("Following") } else { "" }, 22.0, |p, r, _| { avatar(p, pos2(r.max.x - 15.0, r.center().y), 8.0, label, fill) }) }; row(ui, &trf("{name} (you)", &[("name", &me_name)]), me_fill, false); - for (id, name, fill) in &peers { + for (id, name, fill) in peers { if row(ui, name, *fill, follow == Some(*id)) { clicked = Some(*id); ui.close(); @@ -552,17 +583,7 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { ); } }); - if let Some(id) = clicked { - let params = if follow == Some(id) { json!({}) } else { json!({"client": id}) }; - app.run("collab.follow", params); - } - if spotlight { - app.run("collab.spotlight", json!({"on": !spotlighting})); - } - // A server is asked without the window waiting for it. - if let Some(id) = remove { - app.run_background(&ui.ctx().clone(), "collab.removePerson", json!({"client": id}), Box::new(|_, _, _| {})); - } + clicked } /// The Share button's popover: the live session (start, invite link, stop or leave) and export. diff --git a/crates/ui-egui/src/live/agent.rs b/crates/ui-egui/src/live/agent.rs new file mode 100644 index 00000000..b962eccd --- /dev/null +++ b/crates/ui-egui/src/live/agent.rs @@ -0,0 +1,101 @@ +//! An AI agent attached over MCP, shown like a person in a live session while it works: its avatar +//! in the people row and, on the canvas, an outline in its colour around the layers it last edited +//! or selected, with its name. Both go a short while after its last command +//! ([`figcraft_engine::agent`]), and only the file it works in shows them. + +use egui::{Align2, Pos2, Rect, Sense, Shape, Stroke, Ui, pos2, vec2}; +use figcraft_engine::agent::AgentPresence; +use web_time::Instant; + +use crate::FigApp; +use crate::a11y::Tip; +use crate::theme::{AGENT, Tokens}; + +/// The agent at work in the file in front; asks for the frame where it goes. +pub(crate) fn shown<'a>(app: &'a FigApp, ctx: &egui::Context) -> Option<&'a AgentPresence> { + let now = Instant::now(); + let p = app.session.agent_presence_at(now).filter(|p| app.session.ds().is_ok_and(|d| d.uid == p.doc))?; + ctx.request_repaint_after(p.left(now)); + Some(p) +} + +/// Its avatar at `c` in the people row: ringed in its colour while it works, its name as the tip. +pub(super) fn avatar(ui: &mut Ui, c: Pos2, r: f32, name: &str) { + let t = Tokens::get(ui.ctx()); + ui.interact(Rect::from_center_size(c, vec2(2.0 * r, 2.0 * r)), ui.scope_id().with("live_agent"), Sense::hover()).tip(name); + ui.painter().circle_stroke(c, r + 1.0, Stroke::new(2.0, t.panel)); + super::avatar(ui.painter(), c, r, name, AGENT); + ui.painter().circle_stroke(c, r + 2.5, Stroke::new(2.0, AGENT)); +} + +/// Its layers on the page in front outlined in its colour, its name over them. +pub fn draw(app: &FigApp, painter: &egui::Painter, ctx: &egui::Context) { + let Some(p) = shown(app, ctx) else { return }; + let Ok(ds) = app.session.ds() else { return }; + let (doc, page) = (&ds.doc, ds.page); + let mut over: Option = None; + for q in p.layers.iter().filter(|n| doc.page_of(**n) == Some(page)).filter_map(|n| crate::canvas::node_quad(app, doc, *n)) { + painter.add(Shape::closed_line(q.to_vec(), Stroke::new(1.5, AGENT))); + let r = Rect::from_points(&q); + over = Some(over.map_or(r, |o| o.union(r))); + } + if let Some(r) = over { + super::name_tag(painter, pos2(r.min.x, r.min.y - 4.0), Align2::LEFT_BOTTOM, &p.name, AGENT); + } +} + +#[cfg(test)] +mod tests { + use figcraft_engine::Session; + use serde_json::json; + + use super::shown; + use crate::present_tests::Ui; + + fn rect(u: &mut Ui, agent: Option<&str>) { + let mut p = json!({"command": "shape.rectangle", "params": {"x": 0, "y": 0, "width": 40, "height": 40}}); + if let Some(a) = agent { + p["agent"] = json!(a); + } + let ctx = u.ctx.clone(); + crate::control::handle(&mut u.app, &ctx, "engine.execute", &p).unwrap(); + u.frames(2, Vec::new); + } + + fn labels(u: &mut Ui) -> Vec<(String, egui::Pos2)> { + let out = u.frame_output(vec![]); + let nodes = out.platform_output.accesskit_update.map(|t| t.nodes).unwrap_or_default(); + nodes + .into_iter() + .filter_map(|(_, n)| { + let b = n.bounds()?; + Some((n.label()?.to_string(), egui::pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32))) + }) + .collect() + } + + #[test] + fn the_people_menu_still_lists_you_beside_an_agent() { + let mut u = Ui::new(Session::new()); + u.ctx.enable_accesskit(); + rect(&mut u, Some("Claude")); + let (_, at) = labels(&mut u).into_iter().find(|(l, _)| l == "People in this file").expect("the people row's menu"); + u.button(at, true); + u.button(at, false); + u.frames(2, Vec::new); + let shown = labels(&mut u); + assert!(shown.iter().any(|(l, _)| l.ends_with("(you)")), "{shown:?}"); + } + + #[test] + fn an_attached_agent_shows_in_the_file_it_works_in() { + let mut u = Ui::new(Session::new()); + rect(&mut u, None); + assert!(shown(&u.app, &u.ctx).is_none(), "the control channel's own calls are the user's"); + rect(&mut u, Some("Claude")); + let p = shown(&u.app, &u.ctx).expect("the agent shows"); + assert_eq!((p.name.as_str(), p.layers.len()), ("Claude (agent)", 1)); + u.run("file.new", json!({})); + assert!(shown(&u.app, &u.ctx).is_none(), "another file in front shows no agent"); + } +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index acad6f8f..200dd72b 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -79,6 +79,10 @@ pub fn under_white(c: Color32) -> Color32 { if c == BRAND { ACCENT } else { c } } +/// An AI agent attached over MCP: its avatar and the outline of the layers it works on (none of +/// the people colours, so it never looks like someone in a live session). +pub const AGENT: Color32 = Color32::from_rgb(0xF5, 0x9E, 0x0B); + pub const COMPONENT: Color32 = Color32::from_rgb(0x97, 0x47, 0xFF); /// Handoff status badges (white text on these, in both themes). pub const DEV_READY: Color32 = Color32::from_rgb(0x08, 0x7D, 0x4F); diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index 07327054..d587bb4c 100644 --- a/docs/collaboration-parity.md +++ b/docs/collaboration-parity.md @@ -1,6 +1,6 @@ # Collaboration parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (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); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); 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); comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); 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); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (an AI agent attached over MCP shows where it works (#1416); 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); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); 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); comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); 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); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model, @@ -15,6 +15,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). The batches kept for apps that reconnect are bounded in bytes by default (64 MiB; `max_log_mb` on `figcraft-server`), and an app further behind catches up with a snapshot (#1069). Left: server archives beyond Linux | 25–55 | | Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). Placing a layer among its siblings is a binary search, so large pastes and reorders in one batch no longer take quadratic time (#1065). Left: server archives beyond Linux | 25–55 | | Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). A refused batch's fixes take time linear in the batch: each deleted layer is restored once and each entry fix looked up once (#1063). Left: server archives beyond Linux | 25–55 | +| Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors, selection outlines, avatars (an AI agent attached over MCP also shows its avatar and an outline around the layers it last edited or selected while it works, in or out of a session, #1416), follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 | | Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors (one name for live sessions and comments: Preferences ▸ Your name, also in the Share popover and the Join dialog, kept in the browser too; a rename shows at once in the sessions you're in, #1380), selection outlines, avatars, follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 | | Cursor chat, audio, high-fives | yes | **partial, in live sessions**: cursor chat (`/` opens a bubble at your pointer; everyone sees it as you type, it fades a few seconds after the last change, Enter sends and Escape takes it back, 100 characters; `collab.cursorChat` for agents); sent as ephemeral presence the host checks and rate-limits, never stored. No audio, high-fives or emote reactions | 2–5 | | Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen, and the Join dialog then shows the join until its file is ready (Cancel), or why it failed (Try again, #1381); the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser)); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer: no accounts, teams or projects | 8–17 | @@ -28,6 +29,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Where an AI agent works (M8, #1416): an agent attached over MCP (`figcraft-cli mcp --attach`) changed the file with no sign of where or whether it was still at work. Now, while it runs commands, the people row at the top of the right panel shows its avatar, ringed in its own colour and named as its comments are signed ("Claude (agent)"), and the canvas outlines the layers its last edit changed (the innermost ones), or that it last selected, in that colour with its name. Both go 8 seconds after its last command, and only the file it works in shows them. Display only: no undo step, never saved, never sent to a live session's other people, and the user's own commands never make one (`agent_presence_tests` in the engine, `live::agent` tests in the UI). Ready unchanged | | 2026-10-29 | minor | M9, #1380: your name is one setting, shown where you join. Preferences calls it "Your name" (it signs your comments and is what others see in live sessions). The Join dialog shows it, editable, beside the code, as the Share popover does, and the field is empty while it is the default. A new name is kept at once, and the browser build keeps it in the page's local storage, so a reload joins as you (the other preferences still start afresh there). Changing it while in live sessions renames you in each of them: a `name` signal, which the server cleans and answers with your `PeerJoined` to everyone, and newcomers' welcomes list the new name. A rename made while joining, or before a reconnect, goes out once the host reads signals. An older server ignores it, and clearing the name keeps the session's name until you join again. Tests: `collab/src/rename_tests.rs`, `collab-net/src/rename_tests.rs` (two apps over loopback), `ui-egui/src/display_name_tests.rs`, and `figcraft-web`'s `kept` round-trip. Numbers unchanged | | 2026-10-29 | minor | M9, #1381: joining a live session shows its progress. From the click on Join (the Share popover, the home screen or the Join dialog, which an invite link opens) the Join dialog says "Joining live session…" with a spinner and Cancel until the shared file is in its own tab and ready, then the file shows; while it is on its way the join fields don't start a second one. A join that fails or times out says why in the dialog, with Try again (a new join with the same code or link) and Cancel; Escape and the close button cancel too. `collab.leave` takes `session` to end a session by its id, whichever tab is in front. Tests: `live_joining_tests`. Numbers unchanged | | 2026-10-29 | minor | M9, #1075: `figcraft-server` gives requests that change its files (join codes, links, renames, removals, blocks, publishing with the key) a budget of 60 a minute per address and block (`Action::Admin`), on every server, with the server key and with an owner token alike; listing and looking aren't counted. Before, a managing connection with the key had no limit at all and an owner token only the message rate, so one address could ask for hundreds of 10,000-round code stretches and `links.json` writes a second (100 renames in a row: 100 served before, 60 now, then "too many changes to this server's files from your address…"; another address is unaffected). A typed or new join code is found through an index of code hashes to the files holding them, instead of opening every link's code on the server; results are the same, a copied file folder holding the same code included. The protocol is unchanged. Tests: `admin_budget_tests`. Numbers unchanged | diff --git a/docs/mcp.md b/docs/mcp.md index 3c0f8d5a..1e5ab7d8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -19,6 +19,14 @@ The server lives in `crates/mcp` (`figcraft-mcp`) and is started by the CLI: `--attach` also takes `host:port`. The control channel only listens on 127.0.0.1. +While an attached agent works, the app shows where, as it shows a person in a live session: its +avatar, in a colour of its own and named as its comments are signed ("Claude (agent)", see +Comments), joins the people row at the top of the right panel, and the canvas outlines the layers +its last edit changed, or that it last selected, with its name. Both go 8 seconds after its last +command, and only the file it works in shows them. This is display only: no undo step, nothing +saved, nothing sent to a live session's other people, and nothing changes in what the agent may +do. + Attached calls are never automatically replayed after a transport failure: the app may have applied the request before its reply was lost. The error reports that the operation may have completed; inspect the document before retrying an edit. The next explicit call reconnects. @@ -1328,7 +1336,8 @@ joined file opens as a new document). Sessions are per document: every open document has its own session or none, and several run at once (host one document, join another, publish a third). They keep syncing while another document -is active; there your presence is empty (no cursor, selection or view). The `collab.*` commands +is active; there your presence is empty (no cursor, selection or view). An attached agent's own +presence in the app (above) is never part of a live session's presence. The `collab.*` commands and `collab.peers` are about the **active document's** session (`doc.switch {index}` changes which one that is), and `sessions` in `collab.peers` lists them all. Closing a document ends its session only: a host stops hosting it, a guest leaves it. diff --git a/docs/roadmap.md b/docs/roadmap.md index bac54aae..3e11ecf3 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (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); 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); 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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-10-29 · **Change:** minor (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); 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); 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M8, #1416: an attached MCP agent's presence (avatar in the people row, outline and name tag on the layers it last edited or selected), set by `Session::execute_as` and gone 8 seconds after its last command; display only, never synced. Tests: `agent_presence_tests` (engine), `live::agent` (ui-egui) | | 2026-10-30 | minor | M5, #405: a manual overlay's ghost shows on the canvas while its Open overlay action is open in the interaction editor, and dragging it sets the action's offset from the layer that opens it (X and Y follow, one undo step). Tests: `overlay_ghost` (ui-egui) | | 2026-10-29 | minor | M9, #1380: your name is one setting, shown where you join. Preferences calls it "Your name" (it signs your comments and is what others see in live sessions). The Join dialog shows it, editable, beside the code, as the Share popover does, and the field is empty while it is the default. A new name is kept at once, and the browser build keeps it in the page's local storage, so a reload joins as you (the other preferences still start afresh there). Changing it while in live sessions renames you in each of them: a `name` signal, which the server cleans and answers with your `PeerJoined` to everyone, and newcomers' welcomes list the new name. A rename made while joining, or before a reconnect, goes out once the host reads signals. An older server ignores it, and clearing the name keeps the session's name until you join again. Tests: `collab/src/rename_tests.rs`, `collab-net/src/rename_tests.rs` (two apps over loopback), `ui-egui/src/display_name_tests.rs`, and `figcraft-web`'s `kept` round-trip. Numbers unchanged | | 2026-10-29 | minor | A marquee from anywhere in vector edit mode (#1508): a press on another layer used to leave edit mode at once, so a drag there moved that layer; it now draws a marquee that selects the points inside it. A click there still leaves edit mode and selects what it lands on. Numbers unchanged | From 05f811105c3a7afb78cfcd55c3191c3be688d2fd Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 30 Oct 2026 03:04:11 +0700 Subject: [PATCH 55/63] M1: start on other graphics when the window's first ones fail (#965, #1438) (#1557) Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-call validation shaders, the two causes of "Parent device is lost" at start. When the graphics still fail before the first frame, the app starts again with the next of a fixed list of attempts (Windows: Vulkan, OpenGL; Linux and FreeBSD: OpenGL; then lower requirements on the power-saving GPU), and the last failure's message names what was tried. --- ROADMAP.md | 3 +- apps/figcraft/src/control_server.rs | 16 ++- apps/figcraft/src/graphics.rs | 179 +++++++++++++++++++++++++--- apps/figcraft/src/graphics_tests.rs | 148 ++++++++++++++++++++--- apps/figcraft/src/main.rs | 20 ++-- apps/figcraft/src/startup.rs | 7 +- apps/figcraft/src/startup_tests.rs | 17 ++- crates/ui-egui/locales/ar.toml | 1 + crates/ui-egui/locales/bn.toml | 1 + crates/ui-egui/locales/de.toml | 1 + crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/locales/fi.toml | 1 + crates/ui-egui/locales/fr.toml | 1 + crates/ui-egui/locales/it.toml | 1 + crates/ui-egui/locales/ja.toml | 1 + crates/ui-egui/locales/ka.toml | 1 + crates/ui-egui/locales/ko.toml | 1 + crates/ui-egui/locales/nl.toml | 1 + crates/ui-egui/locales/pl.toml | 1 + crates/ui-egui/locales/pt-BR.toml | 1 + crates/ui-egui/locales/ru.toml | 1 + crates/ui-egui/locales/sv.toml | 1 + crates/ui-egui/locales/th.toml | 1 + crates/ui-egui/locales/vi.toml | 1 + crates/ui-egui/locales/zh-Hans.toml | 1 + crates/ui-egui/src/dialogs.rs | 12 +- docs/hardware-parity.md | 5 +- docs/roadmap.md | 3 +- 29 files changed, 374 insertions(+), 55 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e6de5275..e4097ad5 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-29 · **Change:** minor (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); 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); 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-10-29 · **Change:** minor (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); 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); 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 @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M1, #965, #1438: "Parent device is lost" at start. Both reports lost the device while wgpu compiled its indirect-call validation shaders, which it builds with every device: on Windows Direct3D 12 took another program's `dxcompiler.dll` from the search path and failed without its `dxil.dll`; Intel Macs' Metal compiler aborts on them. Direct3D 12 now compiles with FXC (part of Windows; `WGPU_DX12_COMPILER` still chooses), and the validation shaders are left out (FigCraft draws nothing indirectly; `WGPU_VALIDATION_INDIRECT_CALL` turns them back on). When the graphics still fail before the window's first frame, the app starts itself again with the next attempt, from a fixed list: on Windows Direct3D 12/OpenGL, Vulkan, OpenGL, then the first again on the power-saving GPU with WebGL2-level device limits; on Linux and FreeBSD the defaults, OpenGL, then lower requirements; on macOS the defaults, then lower requirements. The last failure's message names what was tried; the log names the backend and GPU that drew. `WGPU_BACKEND` is still the one attempt. A restarted app waits up to 5 s for its `--control` port. Checked on Windows 11 with a local build that made the first one, two, three and then all four attempts fail: each fallback opened the window (Vulkan, OpenGL, Direct3D 12 on the integrated GPU) and four failures ended with the message. Not run on the reporters' machines or on macOS. Tests: `graphics_tests`, `startup_tests`. Numbers unchanged | | 2026-10-29 | minor | M4, #1219: Thai, Lao, Khmer and Burmese text, written without spaces between words, wraps between words instead of where a line overflows (`พัฒนาซอ|ฟต์แวร์` → `พัฒนา|ซอฟต์แวร์`). `figcraft_text::breaks::word_breaks` finds the word starts inside each run of these scripts with ICU4X's line segmenter (`icu_segmenter` 2.3): its dictionaries on desktop, its LSTM models in the browser (about 0.39 MB of wasm instead of 1.86 MB, which the 24 MiB gate can't take; they split a few compounds the dictionaries keep whole). Where a run meets spaces, Latin or CJK the UAX #14 rules decide as before, and a word wider than its box still takes #1400's emergency break. Text without these scripts does no extra work. Grade unchanged. Evidence: `breaks::tests::thai_lines_break_between_words`, `lao_khmer_and_burmese_lines_break_between_words`, `word_breaks_survive_junk`, `behaviour_tests::thai_lines_break_between_words` (fails on main). | | 2026-10-30 | minor | M8, #1416: an AI agent attached over MCP shows where it works, as a person in a live session does: its avatar in its own colour in the people row, named as its comments are signed ("Claude (agent)"), and an outline with its name around the layers its last edit changed or it last selected. It goes 8 seconds after its last command and shows only in the file it works in. Display only (no undo step, not saved, never synced to a live session); the engine records it in `Session::execute_as` (`figcraft_engine::agent`). Tests: `agent_presence_tests` (engine), `live::agent` (ui-egui) | | 2026-10-30 | minor | M5, #405: a manual overlay can be placed on the canvas. While the interaction editor has an Open overlay action with Manual position open, the overlay frame shows dimmed and outlined where it opens, from the top-left of the layer that opens it (right below it without an offset), and dragging it moves it: the X and Y fields follow while dragging, and the drag is one undo step. Tests: `overlay_ghost` (ui-egui) | diff --git a/apps/figcraft/src/control_server.rs b/apps/figcraft/src/control_server.rs index c6344303..a7c08f8a 100644 --- a/apps/figcraft/src/control_server.rs +++ b/apps/figcraft/src/control_server.rs @@ -59,8 +59,20 @@ pub fn drain_queue(queue: &Queue, target: &mut Vec) { target.extend(queue.drain(..count)); } -pub fn start(port: u16, queue: Queue, ctx: CtxSlot, events: Arc) { - let listener = match TcpListener::bind(("127.0.0.1", port)) { +/// Bind `port`, waiting up to `busy` for it while another process holds it: an app started again on +/// other graphics ([`crate::graphics`]) starts beside the one ending, which still holds it. +pub fn bind(port: u16, busy: Duration) -> std::io::Result { + let since = std::time::Instant::now(); + loop { + match TcpListener::bind(("127.0.0.1", port)) { + Err(e) if e.kind() == std::io::ErrorKind::AddrInUse && since.elapsed() < busy => std::thread::sleep(Duration::from_millis(50)), + bound => return bound, + } + } +} + +pub fn start(port: u16, busy: Duration, queue: Queue, ctx: CtxSlot, events: Arc) { + let listener = match bind(port, busy) { Ok(l) => l, Err(e) => { log::error!("control server: can't bind 127.0.0.1:{port}: {e} (another app may own it; pick another port)"); diff --git a/apps/figcraft/src/graphics.rs b/apps/figcraft/src/graphics.rs index ee7b74fa..ec727acc 100644 --- a/apps/figcraft/src/graphics.rs +++ b/apps/figcraft/src/graphics.rs @@ -1,4 +1,4 @@ -//! Which graphics API draws the window. +//! Which graphics API draws the window, and what happens when it can't. //! //! eframe offers wgpu every backend it was built with, and between equal GPUs wgpu takes Vulkan //! first, on Windows too. A Vulkan driver that faults there takes the app down as the window opens: @@ -6,29 +6,178 @@ //! with two AMD GPUs whose drivers were years apart). Direct3D 12 is part of Windows, so on Windows //! wgpu is offered Direct3D 12, and OpenGL for hardware without a Direct3D 12 driver. //! +//! Two shader compilers lost the device before the window opened ("Parent device is lost", #965, +//! #1438), both on wgpu's indirect-call validation shaders, which wgpu compiles with every device: +//! Direct3D 12 took the first `dxcompiler.dll` on the search path, another program's, and failed +//! on the missing `dxil.dll` beside it; Metal's Intel compiler aborts on them on older Macs. So +//! Direct3D 12 compiles with FXC, which is part of Windows, and the validation shaders are left +//! out: FigCraft draws nothing indirectly, so they would check nothing. +//! +//! When the graphics still fail before the window's first frame, the app starts again with the +//! next of its [`attempts`]: on Windows Vulkan, then OpenGL; elsewhere OpenGL; and last the first +//! backends again with lower requirements on the power-saving GPU (macOS has no OpenGL in wgpu, so +//! that is its only other try). The attempts are a fixed list, so this ends; the message the last +//! one shows names what was tried. +//! //! `WGPU_BACKEND` (`dx12`, `vulkan`, `gl`, `metal`) still names the backend on every system, and -//! systems other than Windows keep eframe's defaults. +//! is then the one attempt. `WGPU_DX12_COMPILER` and `WGPU_VALIDATION_INDIRECT_CALL` still choose +//! the compiler and the validation. + +use std::sync::Arc; +use std::time::Duration; use eframe::egui_wgpu::{WgpuConfiguration, WgpuSetup}; -use eframe::wgpu::Backends; +use eframe::wgpu::{self, Backends, Dx12Compiler, InstanceFlags, Limits, PowerPreference}; + +/// Which of the [`attempts`] this start is: set by the app itself when it starts again. +pub const ATTEMPT_ENV: &str = "FIGCRAFT_GRAPHICS_ATTEMPT"; + +/// Frames drawn before a failure below which the window was still starting up: the second frame +/// begins once the first was presented. +pub const STARTUP_FRAMES: u64 = 2; /// Direct3D 12, then OpenGL: wgpu ranks a hardware GPU on either ahead of Direct3D's software one. const WINDOWS: Backends = Backends::DX12.union(Backends::GL); -/// The backends wgpu chooses from: the ones `WGPU_BACKEND` asks for (`requested`), else [`WINDOWS`] -/// on Windows, else eframe's `defaults`. -pub fn backends(requested: Option, windows: bool, defaults: Backends) -> Backends { - match requested { - Some(requested) => requested, - None if windows => WINDOWS, - None => defaults, +/// The graphics APIs by name, in the order the startup message lists them. +const NAMES: [(Backends, &str); 4] = + [(Backends::DX12, "Direct3D 12"), (Backends::METAL, "Metal"), (Backends::VULKAN, "Vulkan"), (Backends::GL, "OpenGL")]; + +/// One way of starting the graphics. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Attempt { + /// The backends wgpu chooses from. + pub backends: Backends, + /// The power-saving GPU and only the device limits OpenGL ES 3 hardware has. + pub lower: bool, +} + +/// The ways to start, in order: only `WGPU_BACKEND`'s backends (`requested`) when it is set; +/// otherwise [`WINDOWS`] on Windows, else eframe's `defaults`, then Vulkan on Windows, OpenGL +/// except on macOS, and those first backends again with lower requirements. +pub fn attempts(requested: Option, windows: bool, macos: bool, defaults: Backends) -> Vec { + let normal = |backends| Attempt { backends, lower: false }; + if let Some(requested) = requested { + return vec![normal(requested)]; + } + let first = if windows { WINDOWS } else { defaults }; + let mut list = vec![normal(first)]; + if windows { + list.push(normal(Backends::VULKAN)); + } + if !macos && first != Backends::GL { + list.push(normal(Backends::GL)); + } + list.push(Attempt { backends: first, lower: true }); + list +} + +/// Which of `count` attempts this start is, from [`ATTEMPT_ENV`] (`value`): the first when it is +/// unset or not a number, the last when it is past the end. +pub fn current(value: Option<&str>, count: usize) -> usize { + value.and_then(|v| v.trim().parse().ok()).unwrap_or(0).min(count.saturating_sub(1)) +} + +/// The attempt to start again with after `attempt` of `count` ended: the next one, when the +/// graphics failed (`graphics_failed`) before the window's first frame and one is left. +pub fn next(attempt: usize, count: usize, graphics_failed: bool, frames: u64) -> Option { + let next = attempt + 1; + (graphics_failed && frames < STARTUP_FRAMES && next < count).then_some(next) +} + +/// How long this start waits for its control port: one started again ([`ATTEMPT_ENV`]) starts +/// beside the app that is ending, which still holds it for a moment. +pub fn port_wait() -> Duration { + if std::env::var_os(ATTEMPT_ENV).is_some() { Duration::from_secs(5) } else { Duration::ZERO } +} + +/// The graphics APIs `attempts` tried among those this build has (`built`), by name. +pub fn names(attempts: &[Attempt], built: Backends) -> String { + let tried = attempts.iter().fold(Backends::empty(), |all, a| all | a.backends) & built; + NAMES.iter().filter(|(b, _)| tried.contains(*b)).map(|(_, name)| *name).collect::>().join(", ") +} + +/// Set up `options` for `attempt` (see the module docs). +pub fn apply(options: &mut WgpuConfiguration, attempt: Attempt) { + let WgpuSetup::CreateNew(create) = &mut options.wgpu_setup else { return }; + let instance = &mut create.instance_descriptor; + instance.backends = attempt.backends; + instance.backend_options.dx12.shader_compiler = Dx12Compiler::from_env().unwrap_or(Dx12Compiler::Fxc); + instance.flags = instance.flags.difference(InstanceFlags::VALIDATION_INDIRECT_CALL).with_env(); + if attempt.lower { + create.power_preference = PowerPreference::from_env().unwrap_or(PowerPreference::LowPower); + create.device_descriptor = Arc::new(|adapter| wgpu::DeviceDescriptor { + label: Some("egui wgpu device"), + // The window's surface still needs textures as large as the adapter allows. + required_limits: Limits::downlevel_webgl2_defaults().using_resolution(adapter.limits()), + ..Default::default() + }); } } -/// Offer wgpu [`backends`] instead of eframe's defaults. -pub fn configure(options: &mut WgpuConfiguration) { - if let WgpuSetup::CreateNew(create) = &mut options.wgpu_setup { - let offered = &mut create.instance_descriptor.backends; - *offered = backends(Backends::from_env(), cfg!(windows), *offered); +/// The attempts of this run and which one it is. +pub struct Plan { + attempts: Vec, + current: usize, +} + +impl Plan { + /// Set up `options` for this start's attempt. + pub fn configure(options: &mut WgpuConfiguration) -> Self { + let defaults = match &options.wgpu_setup { + WgpuSetup::CreateNew(create) => create.instance_descriptor.backends, + WgpuSetup::Existing(_) => Backends::all(), + }; + let attempts = attempts(Backends::from_env(), cfg!(windows), cfg!(target_os = "macos"), defaults); + let current = current(std::env::var(ATTEMPT_ENV).ok().as_deref(), attempts.len()); + if let Some(attempt) = attempts.get(current) { + apply(options, *attempt); + } + Self { attempts, current } + } + + /// The window opened on `adapter`: which GPU draws is the first question in every report. + pub fn opened(&self, adapter: &wgpu::AdapterInfo) { + let text = + format!("graphics: drawing with {:?} on {} ({:?}, driver {})", adapter.backend, adapter.name.trim(), adapter.device_type, adapter.driver); + if self.current == 0 { + log::info!("{text}"); + } else { + log::warn!("{text}, start {} of {}: {:?}", self.current + 1, self.attempts.len(), self.attempts.get(self.current)); + } + } + + /// After the window's run (`result`, after `frames` frames): start again with the next attempt + /// when the graphics failed while the window was starting up, otherwise say why it failed + /// ([`crate::startup::failed`], `quiet` as there). + pub fn finish(&self, result: eframe::Result, frames: u64, quiet: bool) -> eframe::Result { + let Err(error) = &result else { return result }; + if let Some(next) = next(self.current, self.attempts.len(), matches!(error, eframe::Error::Wgpu(_)), frames) { + log::warn!("graphics: {error}; starting again with {:?}", self.attempts.get(next)); + match restart(next) { + Ok(()) => return Ok(()), + Err(e) => log::error!("graphics: starting again failed: {e}"), + } + } + let last = self.current > 0 && self.current + 1 == self.attempts.len(); + let tried = last.then(|| names(&self.attempts, wgpu::Instance::enabled_backend_features())); + crate::startup::failed(error, quiet, tried.as_deref()); + result } } + +/// Start the app again with the same arguments as attempt `attempt`. On Unix the new app replaces +/// this process (an AppImage keeps its files mounted), and this returns only on failure; elsewhere +/// it starts beside this one, which then exits. +fn restart(attempt: usize) -> std::io::Result<()> { + log::logger().flush(); + let mut command = std::process::Command::new(std::env::current_exe()?); + command.args(std::env::args_os().skip(1)).env(ATTEMPT_ENV, attempt.to_string()); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt as _; + Err(command.exec()) + } + #[cfg(not(unix))] + command.spawn().map(|_| ()) +} diff --git a/apps/figcraft/src/graphics_tests.rs b/apps/figcraft/src/graphics_tests.rs index e103d32f..62d79de9 100644 --- a/apps/figcraft/src/graphics_tests.rs +++ b/apps/figcraft/src/graphics_tests.rs @@ -1,32 +1,148 @@ -//! The backends offered to wgpu: no Vulkan on Windows unless it is asked for (a faulting Vulkan -//! driver crashed the app as its window opened), and nothing changed anywhere else. +//! The graphics attempts: no Vulkan on Windows at first (a faulting Vulkan driver crashed the app +//! as its window opened), another backend and then lower requirements when the graphics fail +//! before the first frame (#965, #1438), and never more starts than attempts. -use eframe::wgpu::Backends; +use std::time::Duration; -use crate::graphics::backends; +use eframe::egui_wgpu::{WgpuConfiguration, WgpuError, WgpuSetup, WgpuSetupCreateNew}; +use eframe::wgpu::{Backends, Dx12Compiler, InstanceFlags, PowerPreference}; + +use crate::control_server::bind; +use crate::graphics::{ATTEMPT_ENV, Attempt, Plan, STARTUP_FRAMES, apply, attempts, current, names, next, port_wait}; /// What eframe offers when `WGPU_BACKEND` isn't set. const DEFAULTS: Backends = Backends::PRIMARY.union(Backends::GL); -#[test] -fn windows_is_offered_direct3d_and_not_vulkan() { - let offered = backends(None, true, DEFAULTS); - assert!(offered.contains(Backends::DX12), "Direct3D 12 draws the window"); - assert!(offered.contains(Backends::GL), "OpenGL is there for hardware without a Direct3D 12 driver"); - assert!(!offered.contains(Backends::VULKAN), "a Vulkan driver that faults can't be picked"); +const DX12_GL: Backends = Backends::DX12.union(Backends::GL); + +const fn normal(backends: Backends) -> Attempt { + Attempt { backends, lower: false } +} + +const fn lower(backends: Backends) -> Attempt { + Attempt { backends, lower: true } } #[test] -fn wgpu_backend_names_the_backend_on_every_system() { - for windows in [true, false] { +fn windows_starts_on_direct3d_then_vulkan_then_opengl_then_lower_requirements() { + let list = attempts(None, true, false, DEFAULTS); + assert_eq!(list, [normal(DX12_GL), normal(Backends::VULKAN), normal(Backends::GL), lower(DX12_GL)]); + assert!(!list[0].backends.contains(Backends::VULKAN), "a Vulkan driver that faults can't be picked at first"); +} + +#[test] +fn linux_falls_back_to_opengl_then_lower_requirements() { + assert_eq!(attempts(None, false, false, DEFAULTS), [normal(DEFAULTS), normal(Backends::GL), lower(DEFAULTS)]); + // OpenGL alone is tried once. + assert_eq!(attempts(None, false, false, Backends::GL), [normal(Backends::GL), lower(Backends::GL)]); +} + +#[test] +fn macos_has_no_opengl_so_it_only_lowers_the_requirements() { + assert_eq!(attempts(None, false, true, DEFAULTS), [normal(DEFAULTS), lower(DEFAULTS)]); + assert_eq!(attempts(None, false, true, Backends::METAL), [normal(Backends::METAL), lower(Backends::METAL)]); +} + +#[test] +fn wgpu_backend_names_the_one_attempt_on_every_system() { + for (windows, macos) in [(true, false), (false, false), (false, true)] { for asked in [Backends::VULKAN, Backends::DX12, Backends::GL, Backends::VULKAN | Backends::GL] { - assert_eq!(backends(Some(asked), windows, DEFAULTS), asked, "windows: {windows}"); + assert_eq!(attempts(Some(asked), windows, macos, DEFAULTS), [normal(asked)], "windows: {windows}, macos: {macos}"); } } } #[test] -fn other_systems_keep_eframes_defaults() { - assert_eq!(backends(None, false, DEFAULTS), DEFAULTS); - assert_eq!(backends(None, false, Backends::METAL), Backends::METAL); +fn the_attempt_comes_from_the_restart_and_stays_in_range() { + assert_eq!(current(None, 4), 0); + assert_eq!(current(Some("2"), 4), 2); + assert_eq!(current(Some(" 1 "), 4), 1); + assert_eq!(current(Some("9"), 4), 3, "past the end is the last"); + assert_eq!(current(Some("-1"), 4), 0); + assert_eq!(current(Some("junk"), 4), 0); + assert_eq!(current(Some("1"), 0), 0); +} + +#[test] +fn only_a_graphics_failure_while_starting_up_starts_again_and_never_past_the_last_attempt() { + assert_eq!(next(0, 4, true, 0), Some(1)); + assert_eq!(next(2, 4, true, STARTUP_FRAMES - 1), Some(3)); + assert_eq!(next(3, 4, true, 0), None, "the last attempt ends it"); + assert_eq!(next(0, 1, true, 0), None, "WGPU_BACKEND's one attempt ends it"); + assert_eq!(next(0, 4, false, 0), None, "not the graphics"); + assert_eq!(next(0, 4, true, STARTUP_FRAMES), None, "the window ran"); + // Following the restarts from the first start ends within the list. + for count in 1..6 { + let starts = std::iter::successors(Some(0), |&a| next(a, count, true, 0)).count(); + assert_eq!(starts, count); + } +} + +#[test] +fn the_message_names_the_apis_this_build_has() { + let windows = attempts(None, true, false, DEFAULTS); + assert_eq!(names(&windows, Backends::all()), "Direct3D 12, Vulkan, OpenGL"); + let linux = attempts(None, false, false, DEFAULTS); + assert_eq!(names(&linux, Backends::VULKAN | Backends::GL), "Vulkan, OpenGL"); + let macos = attempts(None, false, true, DEFAULTS); + assert_eq!(names(&macos, Backends::METAL), "Metal"); +} + +fn create_new(options: &WgpuConfiguration) -> Option<&WgpuSetupCreateNew> { + match &options.wgpu_setup { + WgpuSetup::CreateNew(create) => Some(create), + WgpuSetup::Existing(_) => None, + } +} + +#[test] +fn every_attempt_compiles_with_fxc_and_leaves_the_indirect_validation_out() { + for attempt in [normal(Backends::DX12), lower(Backends::METAL)] { + let mut options = WgpuConfiguration::default(); + let power = create_new(&options).map(|c| c.power_preference); + apply(&mut options, attempt); + let create = create_new(&options).expect("eframe creates the instance"); + assert_eq!(create.instance_descriptor.backends, attempt.backends); + if std::env::var_os("WGPU_DX12_COMPILER").is_none() { + assert!(matches!(create.instance_descriptor.backend_options.dx12.shader_compiler, Dx12Compiler::Fxc)); + } + if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() { + assert!(!create.instance_descriptor.flags.contains(InstanceFlags::VALIDATION_INDIRECT_CALL)); + } + if std::env::var_os("WGPU_POWER_PREF").is_none() { + let want = if attempt.lower { Some(PowerPreference::LowPower) } else { power }; + assert_eq!(Some(create.power_preference), want); + } + } +} + +#[test] +fn a_start_again_waits_for_the_control_port_the_ending_app_still_holds() { + let held = std::net::TcpListener::bind(("127.0.0.1", 0)).expect("a free port"); + let port = held.local_addr().expect("its address").port(); + let busy = bind(port, Duration::ZERO).expect_err("the port is held"); + assert_eq!(busy.kind(), std::io::ErrorKind::AddrInUse); + let release = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(200)); + drop(held); + }); + assert!(bind(port, Duration::from_secs(10)).is_ok(), "bound once the other app let go"); + release.join().expect("released"); + if std::env::var_os(ATTEMPT_ENV).is_none() { + assert_eq!(port_wait(), Duration::ZERO, "a first start doesn't wait"); + } +} + +#[test] +fn a_failure_that_starts_nothing_again_ends_with_the_error() { + if std::env::var_os(ATTEMPT_ENV).is_some() { + return; + } + let mut options = WgpuConfiguration::default(); + let plan = Plan::configure(&mut options); + assert!(plan.finish(Ok(()), 0, true).is_ok()); + // A failure that isn't the graphics', and a graphics failure once the window ran: no restart. + assert!(plan.finish(Err(eframe::Error::AppCreation("no app".into())), 0, true).is_err()); + let graphics = eframe::Error::Wgpu(WgpuError::NoSurfaceFormatsAvailable); + assert!(plan.finish(Err(graphics), STARTUP_FRAMES, true).is_err()); } diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index 77f15ee6..363847c8 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -234,7 +234,7 @@ fn main() -> eframe::Result { } let queue: Arc>> = Arc::new(Mutex::new(Vec::new())); if let Some(p) = port { - control_server::start(p, queue.clone(), ctx_slot.clone(), fig.session.events.clone()); + control_server::start(p, graphics::port_wait(), queue.clone(), ctx_slot.clone(), fig.session.events.clone()); } let icon = services::window_icon(); // The app id is the Wayland app_id and the X11 window class on Linux: the desktop entry's name @@ -258,7 +258,9 @@ fn main() -> eframe::Result { viewport = viewport.with_icon(icon); } let mut options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() }; - graphics::configure(&mut options.wgpu_options); + let graphics = graphics::Plan::configure(&mut options.wgpu_options); + // Counts the frames drawn, which tells a window that failed starting up from one that ran. + let window = ctx_slot.clone(); figcraft_ui_egui::windows::install(fig); // Registered before the event loop starts, so it catches the Finder event that launched the app // as well as later ones. Lives until the event loop returns; the app creator only borrows it. @@ -269,7 +271,10 @@ fn main() -> eframe::Result { let result = eframe::run_native( "FigCraft", options, - Box::new(move |cc| { + Box::new(|cc| { + if let Some(state) = &cc.wgpu_render_state { + graphics.opened(&state.adapter.get_info()); + } with_app(|fig| fig.services.set_egui_context(&cc.egui_ctx)); // winit's own Quit (⌘Q) ends the process through `terminate:` without ever asking the // app, so unsaved changes are lost with no warning (issue #857). This menu's Quit @@ -290,11 +295,10 @@ fn main() -> eframe::Result { })) }), ); - // Say why instead of quitting silently: release builds on Windows have no console. - if let Err(e) = &result { - startup::failed(e, port.is_some()); - } - result + let frames = window.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref().map_or(0, egui::Context::cumulative_frame_nr); + // Start again on other graphics, or say why instead of quitting silently: release builds on + // Windows have no console. + graphics.finish(result, frames, port.is_some()) } /// With nothing to open, whether the window starts in a new file rather than on the home screen. diff --git a/apps/figcraft/src/startup.rs b/apps/figcraft/src/startup.rs index a25821f3..8fe9c60c 100644 --- a/apps/figcraft/src/startup.rs +++ b/apps/figcraft/src/startup.rs @@ -5,10 +5,11 @@ //! and the app quit without a word (#261). Now it is logged and shown in a message box. /// Log the failure and, unless `quiet` (started for an agent with `--control`: a modal box would -/// keep the caller waiting, and it reads standard error), show it in a message box. -pub fn failed(error: &eframe::Error, quiet: bool) { +/// keep the caller waiting, and it reads standard error), show it in a message box. `tried`: the +/// graphics APIs the app started again with before this failure ([`crate::graphics::Plan::finish`]). +pub fn failed(error: &eframe::Error, quiet: bool, tried: Option<&str>) { // In the interface language: the text and its catalog entries are the UI crate's. - let text = figcraft_ui_egui::dialogs::window_failed(&error.to_string(), std::env::var_os("WGPU_BACKEND").is_some()); + let text = figcraft_ui_egui::dialogs::window_failed(&error.to_string(), std::env::var_os("WGPU_BACKEND").is_some(), tried); // Standard error even when RUST_LOG turned errors off. if log::log_enabled!(log::Level::Error) { log::error!("{text}"); diff --git a/apps/figcraft/src/startup_tests.rs b/apps/figcraft/src/startup_tests.rs index 20910309..c9208175 100644 --- a/apps/figcraft/src/startup_tests.rs +++ b/apps/figcraft/src/startup_tests.rs @@ -14,14 +14,14 @@ const DRIVER_HINT: &str = if cfg!(windows) { #[test] fn the_message_carries_the_error_and_where_to_report_it() { - let text = message(ERROR, false); + let text = message(ERROR, false, None); assert!(text.contains(ERROR), "the error itself: {text}"); assert!(text.contains("https://github.com/storytold/figcraft/issues"), "where to report it: {text}"); } #[test] fn without_wgpu_backend_it_suggests_the_driver_and_another_graphics_api() { - let text = message(ERROR, false); + let text = message(ERROR, false, None); assert!(text.contains(tr(DRIVER_HINT)), "{text}"); assert!(text.contains("WGPU_BACKEND"), "another graphics API to try: {text}"); assert!(!text.contains(tr(BACKEND_HINT)), "{text}"); @@ -30,13 +30,22 @@ fn without_wgpu_backend_it_suggests_the_driver_and_another_graphics_api() { #[cfg(windows)] #[test] fn on_windows_it_shows_how_to_start_with_opengl() { - let text = message(ERROR, false); + let text = message(ERROR, false, None); assert!(text.contains("set WGPU_BACKEND=gl"), "a release build has no console to look this up from: {text}"); } #[test] fn with_wgpu_backend_set_it_suggests_unsetting_it() { - let text = message(ERROR, true); + let text = message(ERROR, true, None); assert!(text.contains(tr(BACKEND_HINT)), "{text}"); assert!(!text.contains(tr(DRIVER_HINT)), "the chosen backend is the likelier cause: {text}"); } + +#[test] +fn after_every_attempt_failed_it_names_what_was_tried_and_no_other_api() { + let text = message(ERROR, false, Some("Direct3D 12, Vulkan, OpenGL")); + assert!(text.contains(ERROR), "the last error: {text}"); + assert!(text.contains("Direct3D 12, Vulkan, OpenGL"), "what was tried: {text}"); + assert!(!text.contains(tr(DRIVER_HINT)), "every API was tried already: {text}"); + assert!(text.contains("https://github.com/storytold/figcraft/issues"), "where to report it: {text}"); +} diff --git a/crates/ui-egui/locales/ar.toml b/crates/ui-egui/locales/ar.toml index b3ce8421..de18ce0f 100644 --- a/crates/ui-egui/locales/ar.toml +++ b/crates/ui-egui/locales/ar.toml @@ -1558,6 +1558,7 @@ status = "draft — needs native review" "Updated {name}" = "حُدّث {name}" "Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "غالبًا ما يفيد تحديث تعريف الرسوميات. لاستخدام OpenGL وحده، شغّل FigCraft من موجه الأوامر باستخدام:" "Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "غالبًا ما يفيد تحديث تعريف الرسوميات. يحدد WGPU_BACKEND (vulkan أو gl أو metal) واجهة رسوميات أخرى لتجربتها." +"FigCraft tried {apis}, then again with lower requirements on the power-saving GPU. Updating the graphics driver often helps." = "جرّب FigCraft {apis}، ثم مرة أخرى بمتطلبات أقل على وحدة الرسوميات الموفّرة للطاقة. غالبًا ما يفيد تحديث تعريف الرسوميات." "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "يرفع هذا الملف إلى خادم FigCraft ويبقي علامة التبويب متزامنة معه. تحصل على رابط للتحرير وآخر للعرض." "Uppercase" = "أحرف كبيرة" "Use a fixed value" = "استخدام قيمة ثابتة" diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml index d30d906e..50c50527 100644 --- a/crates/ui-egui/locales/bn.toml +++ b/crates/ui-egui/locales/bn.toml @@ -1505,6 +1505,7 @@ status = "draft — needs native review" "Updated {name}" = "{name} আপডেট করা হয়েছে" "Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "গ্রাফিক্স ড্রাইভার আপডেট করা প্রায়ই সহায়ক হয়। শুধুমাত্র OpenGL ব্যবহার করতে, কমান্ড প্রম্পট থেকে FigCraft শুরু করুন:" "Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "গ্রাফিক্স ড্রাইভার আপডেট করা প্রায়ই সহায়ক হয়। WGPU_BACKEND (vulkan, gl, metal) চেষ্টা করার জন্য অন্য একটি গ্রাফিক্স API এর নাম বলে।" +"FigCraft tried {apis}, then again with lower requirements on the power-saving GPU. Updating the graphics driver often helps." = "FigCraft {apis} চেষ্টা করেছে, তারপর শক্তি-সাশ্রয়ী GPU-তে কম চাহিদায় আবার চেষ্টা করেছে। গ্রাফিক্স ড্রাইভার আপডেট করা প্রায়ই সহায়ক হয়।" "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "এই ফাইলটি একটি FigCraft সার্ভারে আপলোড করে এবং এই ট্যাবটিকে এর সাথে সিঙ্ক করে রাখে। আপনি এডিট করার জন্য একটি লিংক এবং দেখার জন্য একটি লিংক পান।" "Uppercase" = "বড় হাতের অক্ষর" "Use a fixed value" = "একটি নির্দিষ্ট মান ব্যবহার করুন" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index c6d6b602..aad97707 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -1435,6 +1435,7 @@ status = "draft — needs native review" "Updated {name}" = "{name} aktualisiert" "Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Oft hilft es, den Grafiktreiber zu aktualisieren. Um nur OpenGL zu verwenden, starten Sie FigCraft in der Eingabeaufforderung mit:" "Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Oft hilft es, den Grafiktreiber zu aktualisieren. Mit WGPU_BACKEND (vulkan, gl, metal) lässt sich eine andere Grafik-API ausprobieren." +"FigCraft tried {apis}, then again with lower requirements on the power-saving GPU. Updating the graphics driver often helps." = "FigCraft hat {apis} versucht und dann noch einmal mit geringeren Anforderungen auf der stromsparenden GPU. Oft hilft es, den Grafiktreiber zu aktualisieren." "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Lädt diese Datei auf einen FigCraft-Server hoch und hält diesen Tab mit ihm synchron. Sie erhalten einen Link zum Bearbeiten und einen zum Ansehen." "Uppercase" = "Großbuchstaben" "Use a fixed value" = "Festen Wert verwenden" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index b477a4a1..2270cb70 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1569,6 +1569,7 @@ name = "English" "Updated {name}" = "Updated {name}" "Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" "Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." +"FigCraft tried {apis}, then again with lower requirements on the power-saving GPU. Updating the graphics driver often helps." = "FigCraft tried {apis}, then again with lower requirements on the power-saving GPU. Updating the graphics driver often helps." "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." "Uppercase" = "Uppercase" "Use a fixed value" = "Use a fixed value" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index d7f7c7c4..31cdf204 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1547,6 +1547,7 @@ status = "draft — needs native review" "Updated {name}" = "{name} actualizado" "Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Actualizar el controlador gráfico suele ayudar. Para usar solo OpenGL, inicia FigCraft desde el Símbolo del sistema con:" "Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Actualizar el controlador gráfico suele ayudar. WGPU_BACKEND (vulkan, gl, metal) indica otra API gráfica que probar." +"FigCraft tried {apis}, then again with lower requirements on the power-saving GPU. Updating the graphics driver often helps." = "FigCraft probó {apis} y luego otra vez con menos requisitos en la GPU de bajo consumo. Actualizar el controlador gráfico suele ayudar." "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Sube este archivo a un servidor FigCraft y mantiene esta pestaña sincronizada con él. Obtienes un enlace para editar y otro para ver." "Uppercase" = "Mayúsculas" "Use a fixed value" = "Usar un valor fijo" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index befe2ae9..a5ec89ac 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -1510,6 +1510,7 @@ status = "draft — needs native review" "Updated {name}" = "Päivitetty: {name}" "Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Näytönohjainajurin päivittäminen auttaa usein. Käytä vain OpenGL:ää käynnistämällä FigCraft komentokehotteesta näin:" "Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Näytönohjainajurin päivittäminen auttaa usein. WGPU_BACKEND (vulkan, gl, metal) määrittää toisen kokeiltavan grafiikkarajapinnan." +"FigCraft tried {apis}, then again with lower requirements on the power-saving GPU. Updating the graphics driver often helps." = "FigCraft kokeili {apis} ja sitten uudelleen pienemmillä vaatimuksilla virtaa säästävällä näytönohjaimella. Näytönohjainajurin päivittäminen auttaa usein." "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Lataa tämän tiedoston FigCraft-palvelimelle ja pitää tämän välilehden synkronoituna sen kanssa. Saat muokkauslinkin ja katselulinkin." "Uppercase" = "Isot kirjaimet" "Use a fixed value" = "Käytä kiinteää arvoa" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 327fbd4c..ed1efeff 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -1510,6 +1510,7 @@ status = "draft — needs native review" "Updated {name}" = "{name} mis à jour" "Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Mettre à jour le pilote graphique aide souvent. Pour n’utiliser qu’OpenGL, lancez FigCraft depuis l’invite de commandes avec :" "Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Mettre à jour le pilote graphique aide souvent. WGPU_BACKEND (vulkan, gl, metal) désigne une autre API graphique à essayer." +"FigCraft tried {apis}, then again with lower requirements on the power-saving GPU. Updating the graphics driver often helps." = "FigCraft a essayé {apis}, puis à nouveau avec des exigences réduites sur le GPU économe en énergie. Mettre à jour le pilote graphique aide souvent." "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Envoie ce fichier sur un serveur FigCraft et garde cet onglet synchronisé avec lui. Vous obtenez un lien de modification et un lien de consultation." "Uppercase" = "Majuscules" "Use a fixed value" = "Utiliser une valeur fixe" diff --git a/crates/ui-egui/locales/it.toml b/crates/ui-egui/locales/it.toml index ab5e9cfd..2b58caca 100644 --- a/crates/ui-egui/locales/it.toml +++ b/crates/ui-egui/locales/it.toml @@ -1452,6 +1452,7 @@ status = "draft — needs native review" "Updated {name}" = "Aggiornato {name}" "Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Aggiornare il driver grafico spesso aiuta. Per usare solo OpenGL, avvia FigCraft dal Prompt dei comandi con:" "Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Aggiornare il driver grafico spesso aiuta. WGPU_BACKEND (vulkan, gl, metal) indica un'altra API grafica da provare." +"FigCraft tried {apis}, then again with lower requirements on the power-saving GPU. Updating the graphics driver often helps." = "FigCraft ha provato {apis}, poi di nuovo con requisiti ridotti sulla GPU a basso consumo. Aggiornare il driver grafico spesso aiuta." "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Carica questo file su un server FigCraft e mantiene questa scheda sincronizzata con esso. Ricevi un link per modificare e un link per visualizzare." "Uppercase" = "Maiuscolo" "Use a fixed value" = "Usa un valore fisso" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index f5b083d0..631d5038 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -1493,6 +1493,7 @@ status = "draft — needs native review" "Updated {name}" = "{name} を更新済み" "Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "グラフィックスドライバーの更新で解決することが多い。OpenGL だけを使うには、コマンドプロンプトから次のように FigCraft を起動:" "Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "グラフィックスドライバーの更新で解決することが多い。WGPU_BACKEND(vulkan、gl、metal)で別のグラフィックス API を試せる。" +"FigCraft tried {apis}, then again with lower requirements on the power-saving GPU. Updating the graphics driver often helps." = "FigCraft は {apis} を試し、さらに省電力 GPU で要件を下げて再試行した。グラフィックスドライバーの更新で解決することが多い。" "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "このファイルを FigCraft サーバーにアップロードし、このタブを同期した状態に保つ。編集用リンクと閲覧用リンクが発行される。" "Uppercase" = "大文字" "Use a fixed value" = "固定値を使用" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index 6986376b..ce2abc40 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -1528,6 +1528,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Updated {name}" = "განახლდა: {name}" "Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "ხშირად გრაფიკის დრაივერის განახლება შველის. მხოლოდ OpenGL-ის გამოსაყენებლად FigCraft გაუშვით ბრძანების სტრიქონიდან (Command Prompt) ასე:" "Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "ხშირად გრაფიკის დრაივერის განახლება შველის. WGPU_BACKEND-ით (vulkan, gl, metal) შეგიძლიათ სხვა გრაფიკული API სცადოთ." +"FigCraft tried {apis}, then again with lower requirements on the power-saving GPU. Updating the graphics driver often helps." = "FigCraft-მა სცადა {apis}, შემდეგ კი ისევ, ნაკლები მოთხოვნებით, ენერგოდამზოგ GPU-ზე. ხშირად გრაფიკის დრაივერის განახლება შველის." "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "ტვირთავს ამ ფაილს FigCraft-ის სერვერზე და ამ ჩანართს მასთან სინქრონში ინახავს. მიიღებთ რედაქტირებისა და ნახვის ბმულებს." "Uppercase" = "მთავრული" "Use a fixed value" = "ფიქსირებული მნიშვნელობის გამოყენება" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index f0c1a3e4..a888dbe9 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -1426,6 +1426,7 @@ status = "draft — needs native review" "Updated {name}" = "{name} 업데이트됨" "Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "그래픽 드라이버를 업데이트하면 해결되는 경우가 많습니다. OpenGL만 사용하려면 명령 프롬프트에서 다음과 같이 FigCraft를 시작하세요:" "Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "그래픽 드라이버를 업데이트하면 해결되는 경우가 많습니다. WGPU_BACKEND(vulkan, gl, metal)로 다른 그래픽 API를 시도할 수 있습니다." +"FigCraft tried {apis}, then again with lower requirements on the power-saving GPU. Updating the graphics driver often helps." = "FigCraft에서 {apis}을(를) 시도한 뒤 저전력 GPU에서 요구 사항을 낮춰 다시 시도했습니다. 그래픽 드라이버를 업데이트하면 해결되는 경우가 많습니다." "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "이 파일을 FigCraft 서버에 업로드하고 이 탭을 동기화된 상태로 유지함. 편집 링크와 보기 링크가 생성됨." "Uppercase" = "대문자" "Use a fixed value" = "고정 값 사용" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index 5c8cc3d8..ed9a7c94 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -1510,6 +1510,7 @@ status = "draft — needs native review" "Updated {name}" = "{name} bijgewerkt" "Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Het bijwerken van de grafische driver helpt vaak. Om alleen OpenGL te gebruiken, start je FigCraft vanaf de Opdrachtprompt met:" "Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Het bijwerken van de grafische driver helpt vaak. WGPU_BACKEND (vulkan, gl, metal) noemt een andere grafische API om te proberen." +"FigCraft tried {apis}, then again with lower requirements on the power-saving GPU. Updating the graphics driver often helps." = "FigCraft heeft {apis} geprobeerd en daarna opnieuw met lagere eisen op de energiezuinige GPU. Het bijwerken van de grafische driver helpt vaak." "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Uploadt dit bestand naar een FigCraft-server en houdt dit tabblad daarmee gesynchroniseerd. Je krijgt een link om te bewerken en een link om te bekijken." "Uppercase" = "Hoofdletters" "Use a fixed value" = "Gebruik een vaste waarde" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index 90e53724..dadbebf9 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -1519,6 +1519,7 @@ name = "Polski" "Updated {name}" = "Zaktualizowano {name}" "Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Aktualizacja sterownika graficznego często pomaga. Aby używać tylko OpenGL, uruchom FigCraft z wiersza poleceń za pomocą:" "Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Aktualizacja sterownika graficznego często pomaga. Zmienna WGPU_BACKEND (vulkan, gl, metal) pozwala wybrać inne graficzne API do wypróbowania." +"FigCraft tried {apis}, then again with lower requirements on the power-saving GPU. Updating the graphics driver often helps." = "FigCraft wypróbował {apis}, a potem ponownie z niższymi wymaganiami na energooszczędnym GPU. Aktualizacja sterownika graficznego często pomaga." "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Przesyła ten plik na serwer FigCraft i synchronizuje tę kartę na bieżąco. Otrzymujesz link do edycji oraz link do podglądu." "Uppercase" = "WIELKIE LITERY" "Use a fixed value" = "Użyj stałej wartości" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index fbc12f68..f3811fad 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -1424,6 +1424,7 @@ status = "draft — needs native review" "Updated {name}" = "{name} atualizado" "Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Atualizar o driver de vídeo costuma ajudar. Para usar só o OpenGL, abra o FigCraft pelo Prompt de Comando com:" "Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Atualizar o driver de vídeo costuma ajudar. WGPU_BACKEND (vulkan, gl, metal) indica outra API gráfica para tentar." +"FigCraft tried {apis}, then again with lower requirements on the power-saving GPU. Updating the graphics driver often helps." = "O FigCraft tentou {apis} e depois de novo com requisitos menores na GPU de baixo consumo. Atualizar o driver de vídeo costuma ajudar." "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Envia este arquivo para um servidor FigCraft e mantém esta aba sincronizada com ele. Você recebe um link para editar e outro para visualizar." "Uppercase" = "Maiúsculas" "Use a fixed value" = "Usar um valor fixo" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 11449a43..d74290b8 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -1519,6 +1519,7 @@ name = "Русский" "Updated {name}" = "Обновлено: {name}" "Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Часто помогает обновление драйвера видеокарты. Чтобы использовать только OpenGL, запустите FigCraft из командной строки с параметром:" "Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Часто помогает обновление драйвера видеокарты. Переменная WGPU_BACKEND (vulkan, gl, metal) задаёт другой графический API, который можно попробовать." +"FigCraft tried {apis}, then again with lower requirements on the power-saving GPU. Updating the graphics driver often helps." = "FigCraft попробовал {apis}, а затем ещё раз с пониженными требованиями на энергосберегающем GPU. Часто помогает обновление драйвера видеокарты." "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Загружает файл на сервер FigCraft и синхронизирует эту вкладку с ним. Вы получите ссылки для редактирования и просмотра." "Uppercase" = "Прописные" "Use a fixed value" = "Использовать фиксированное значение" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index 9fd05c69..5dca7e04 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -1553,6 +1553,7 @@ name = "Svenska" "Updated {name}" = "{name} har uppdaterats" "Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Det hjälper ofta att uppdatera grafikdrivrutinen. Om du bara vill använda OpenGL startar du FigCraft från en kommandotolk med:" "Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Det hjälper ofta att uppdatera grafikdrivrutinen. WGPU_BACKEND (vulkan, gl, metal) anger ett annat grafik-API att prova." +"FigCraft tried {apis}, then again with lower requirements on the power-saving GPU. Updating the graphics driver often helps." = "FigCraft provade {apis} och sedan igen med lägre krav på den strömsnåla grafikprocessorn. Det hjälper ofta att uppdatera grafikdrivrutinen." "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Laddar upp filen till en FigCraft-server och håller fliken synkroniserad med den. Du får en länk för redigering och en för visning." "Uppercase" = "Versaler" "Use a fixed value" = "Använd ett fast värde" diff --git a/crates/ui-egui/locales/th.toml b/crates/ui-egui/locales/th.toml index d281f598..d41408a8 100644 --- a/crates/ui-egui/locales/th.toml +++ b/crates/ui-egui/locales/th.toml @@ -1501,6 +1501,7 @@ status = "draft — needs native review" "Updated {name}" = "อัปเดต {name} แล้ว" "Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "การอัปเดตไดรเวอร์กราฟิกมักช่วยได้ หากต้องการใช้ OpenGL เท่านั้น ให้เริ่ม FigCraft จาก Command Prompt ด้วย:" "Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "การอัปเดตไดรเวอร์กราฟิกมักช่วยได้ WGPU_BACKEND (vulkan, gl, metal) ระบุ API กราฟิกอื่นที่ลองใช้ได้" +"FigCraft tried {apis}, then again with lower requirements on the power-saving GPU. Updating the graphics driver often helps." = "FigCraft ลอง {apis} แล้ว จากนั้นลองอีกครั้งด้วยข้อกำหนดที่ต่ำลงบน GPU แบบประหยัดพลังงาน การอัปเดตไดรเวอร์กราฟิกมักช่วยได้" "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "อัปโหลดไฟล์นี้ไปยังเซิร์ฟเวอร์ FigCraft และซิงก์แท็บนี้กับไฟล์ คุณจะได้ลิงก์แก้ไขและลิงก์ดู" "Uppercase" = "ตัวพิมพ์ใหญ่" "Use a fixed value" = "ใช้ค่าคงที่" diff --git a/crates/ui-egui/locales/vi.toml b/crates/ui-egui/locales/vi.toml index e8e948e3..76ae4303 100644 --- a/crates/ui-egui/locales/vi.toml +++ b/crates/ui-egui/locales/vi.toml @@ -1453,6 +1453,7 @@ status = "draft — needs native review" "Updated {name}" = "Đã cập nhật {name}" "Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Việc cập nhật trình điều khiển đồ họa thường sẽ khắc phục được lỗi. Để chỉ sử dụng OpenGL, hãy khởi chạy FigCraft từ Command Prompt bằng:" "Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Việc cập nhật trình điều khiển đồ họa thường sẽ khắc phục được lỗi. Biến WGPU_BACKEND (vulkan, gl, metal) cho phép thử một API đồ họa khác." +"FigCraft tried {apis}, then again with lower requirements on the power-saving GPU. Updating the graphics driver often helps." = "FigCraft đã thử {apis}, rồi thử lại với yêu cầu thấp hơn trên GPU tiết kiệm điện. Việc cập nhật trình điều khiển đồ họa thường sẽ khắc phục được lỗi." "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Tải tệp này lên máy chủ FigCraft và giữ thẻ này đồng bộ với máy chủ. Bạn sẽ nhận được một liên kết chỉnh sửa và một liên kết xem." "Uppercase" = "Chữ hoa" "Use a fixed value" = "Dùng giá trị cố định" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 72c9b585..e8eb093b 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -1434,6 +1434,7 @@ status = "draft — needs native review" "Updated {name}" = "已更新 {name}" "Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "更新显卡驱动通常会有帮助。若只想使用 OpenGL,请在命令提示符中这样启动 FigCraft:" "Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "更新显卡驱动通常会有帮助。可用 WGPU_BACKEND(vulkan、gl、metal)指定另一个图形 API 来尝试。" +"FigCraft tried {apis}, then again with lower requirements on the power-saving GPU. Updating the graphics driver often helps." = "FigCraft 已尝试 {apis},然后在省电 GPU 上以较低要求再次尝试。更新显卡驱动通常会有帮助。" "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "将此文件上传到 FigCraft 服务器,并使此标签页与之保持同步。你会获得一个编辑链接和一个查看链接。" "Uppercase" = "大写" "Use a fixed value" = "使用固定值" diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c266bebb..fe3bdb73 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -44,9 +44,15 @@ const ISSUES: &str = "https://github.com/storytold/figcraft/issues"; /// What the desktop app says when its window couldn't open with `error` (no frame was drawn: the /// language is the one [`i18n::set`] picked from the preferences). `backend_set`: `WGPU_BACKEND` -/// named the graphics API, so the hint is to unset it rather than to try another one. -pub fn window_failed(error: &str, backend_set: bool) -> String { - let hint = if backend_set { +/// named the graphics API, so the hint is to unset it rather than to try another one. `tried`: the +/// graphics APIs the app already started again with, so naming another one wouldn't help. +pub fn window_failed(error: &str, backend_set: bool, tried: Option<&str>) -> String { + let hint = if let Some(apis) = tried { + trf( + "FigCraft tried {apis}, then again with lower requirements on the power-saving GPU. Updating the graphics driver often helps.", + &[("apis", &apis)], + ) + } else if backend_set { tr("WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose.").to_string() } else if cfg!(windows) { // Direct3D 12 and OpenGL are both offered, but wgpu keeps the Direct3D 12 adapter it picked diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 76da477d..5bc8f89a 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware and platform parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7) How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out @@ -40,7 +40,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import). | Platform | Figma | FigCraft | Notes | |---|---|---|---| | macOS | desktop app (Intel and Apple silicon) | universal `.dmg`, signed and notarized (v0.2.0) | where nearly all QA happens. Quitting ends the process once the preferences, recovery copies and live sessions are taken care of: the window's teardown crashed on Macs with a Touch Bar (#781); the Quit menu and ⌘Q ask about unsaved changes like the main window's close button (#857), and like it close the main window only: with several windows open, each quit closes one; a quit that never asked — killed or crashed — still leaves recovery copies for the next start | -| Windows x64 / arm64 | desktop app | `.msi` and portable zip for x64, x86 and arm64 | built in CI; Windows: QA pass done 2026-10-11, findings in PR | +| Windows x64 / arm64 | desktop app | `.msi` and portable zip for x64, x86 and arm64 | built in CI; Windows: QA pass done 2026-10-11, findings in PR. Draws with Direct3D 12 (FXC shaders) or OpenGL; when the graphics fail before the first frame it starts again on Vulkan, then OpenGL, then the power-saving GPU with lower limits (#965) | | Linux | none (browser only) | AppImage, `.deb`, `.rpm`, Flatpak, tarball, x86_64 and aarch64; riscv64 tarball | CI tests run here. Clipboard (also the BSDs): reads run on a helper thread that gives up after 2 s, and images copied in other apps paste under Wayland through the data-control protocol (#693). Under Wayland the app can't tell its window is minimised, so control requests that need a drawn frame may still wait there (#720). None of this run on a Linux desktop here. On a Wayland compositor without server-side decorations (GNOME) the window gets winit's Adwaita controls (#592: checked by its author in a GNOME Wayland session; KDE not run) | | FreeBSD | none | tarball | | | Web | primary (figma.com) | `figcraft-web` (WASM, WebGPU/WebGL2), web zip | no accessibility tree; file access through the browser; copy and paste through the system clipboard (copies written from the copy event, pastes read from the paste event; no pasting images from other apps) | @@ -50,6 +50,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M1, #965, #1438: "Parent device is lost" at start. Both reports lost the device while wgpu compiled its indirect-call validation shaders, which it builds with every device: on Windows Direct3D 12 took another program's `dxcompiler.dll` from the search path and failed without its `dxil.dll`; Intel Macs' Metal compiler aborts on them. Direct3D 12 now compiles with FXC (part of Windows; `WGPU_DX12_COMPILER` still chooses), and the validation shaders are left out (FigCraft draws nothing indirectly; `WGPU_VALIDATION_INDIRECT_CALL` turns them back on). When the graphics still fail before the window's first frame, the app starts itself again with the next attempt, from a fixed list: on Windows Direct3D 12/OpenGL, Vulkan, OpenGL, then the first again on the power-saving GPU with WebGL2-level device limits; on Linux and FreeBSD the defaults, OpenGL, then lower requirements; on macOS the defaults, then lower requirements. The last failure's message names what was tried; the log names the backend and GPU that drew. `WGPU_BACKEND` is still the one attempt. A restarted app waits up to 5 s for its `--control` port. Checked on Windows 11 with a local build that made the first one, two, three and then all four attempts fail: each fallback opened the window (Vulkan, OpenGL, Direct3D 12 on the integrated GPU) and four failures ended with the message. Not run on the reporters' machines or on macOS. Tests: `graphics_tests`, `startup_tests`. Numbers unchanged | | 2026-10-29 | minor | Large images on the canvas (#1483): an image shown smaller than its pixels draws on the canvas from a copy scaled down to 4096 or 8192 px on its longer side, the smallest that holds what the screen shows (`figcraft_render::images::shown`), cached beside the image itself in the same 512 MB budget. Two 16 000 × 10 000 images took 640 MB each and evicted each other on every frame that drew both; as copies they take 80 MB together zoomed out and 336 MB at 8192. Moving one over the other went from about 2.1 s a frame to 11 ms zoomed to fit and 15 ms at 30% in the app (debug build). Closer than that, and for tiles, the canvas draws from the image itself, so 100% and closer stay sharp; a view of both at 100% still takes about 2 s a frame, and an image with shadows still decodes in full for them. Exports, thumbnails and every other render are unchanged. Tests: `large_image_tests`. Numbers unchanged | | 2026-10-29 | minor | macOS quit confirmation (#857): winit's default Quit menu item (⌘Q) ended the process through AppKit's `terminate:` at once, so a dirty app died without asking anything. The app now installs the craft-libs app menu (craft-menu): its Quit sends the main window a close request — the same path as its close button — so the unsaved-changes question asks before anything is lost (`windows_tests::a_quit_with_unsaved_changes_waits_for_the_answer`; checked in the app on macOS). Like the close button it closes the main window only, so with several windows open each quit closes one. A second quit before the window draws again (two quits while it is minimised, when only the app's logic runs) counts once: before, it could quit the app over another window's unsaved changes (`windows_tests::quitting_twice_before_the_window_draws_closes_it_once`). Numbers unchanged | | 2026-10-25 | minor | egui/eframe 0.37 dependency migration: the workspace pins the egui family at 0.37, and the wide-gamut and several-windows rows now cite 0.37. The limitations are unchanged: egui-wgpu 0.37 offers no surface colour-space option, so the screen still shows sRGB; eframe 0.37 still gives only the main window an AccessKit adapter, so files stay tabs while a screen reader runs. Numbers unchanged | diff --git a/docs/roadmap.md b/docs/roadmap.md index 2375cf28..f08a455f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (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); 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); 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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-10-29 · **Change:** minor (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); 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); 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M1, #965, #1438: "Parent device is lost" at start: Direct3D 12 compiles with FXC, no device compiles wgpu's indirect-validation shaders, and when the graphics still fail before the first frame the app starts again with the next of a fixed list of attempts (Windows: Vulkan, OpenGL; Linux and FreeBSD: OpenGL; then lower requirements on the power-saving GPU); the last failure names what was tried. Tests: `graphics_tests`, `startup_tests` | | 2026-10-29 | minor | M4, #1219: Thai, Lao, Khmer and Burmese text, written without spaces between words, wraps between words instead of where a line overflows (`พัฒนาซอ|ฟต์แวร์` → `พัฒนา|ซอฟต์แวร์`). `figcraft_text::breaks::word_breaks` finds the word starts inside each run of these scripts with ICU4X's line segmenter (`icu_segmenter` 2.3): its dictionaries on desktop, its LSTM models in the browser (about 0.39 MB of wasm instead of 1.86 MB, which the 24 MiB gate can't take; they split a few compounds the dictionaries keep whole). Where a run meets spaces, Latin or CJK the UAX #14 rules decide as before, and a word wider than its box still takes #1400's emergency break. Text without these scripts does no extra work. Grade unchanged. Evidence: `breaks::tests::thai_lines_break_between_words`, `lao_khmer_and_burmese_lines_break_between_words`, `word_breaks_survive_junk`, `behaviour_tests::thai_lines_break_between_words` (fails on main). | | 2026-10-30 | minor | M8, #1416: an attached MCP agent's presence (avatar in the people row, outline and name tag on the layers it last edited or selected), set by `Session::execute_as` and gone 8 seconds after its last command; display only, never synced. Tests: `agent_presence_tests` (engine), `live::agent` (ui-egui) | | 2026-10-30 | minor | M5, #405: a manual overlay's ghost shows on the canvas while its Open overlay action is open in the interaction editor, and dragging it sets the action's offset from the layer that opens it (X and Y follow, one undo step). Tests: `overlay_ghost` (ui-egui) | From 28a0fe6d18ea6ddef2bf818f0ef3cb65d6401da9 Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 30 Oct 2026 03:23:11 +0700 Subject: [PATCH 56/63] M9: the admin budget test allows for the bucket refilling during a slow run (#1564) --- apps/figcraft-server/src/admin_budget_tests.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/figcraft-server/src/admin_budget_tests.rs b/apps/figcraft-server/src/admin_budget_tests.rs index cee514b6..fed3e5d9 100644 --- a/apps/figcraft-server/src/admin_budget_tests.rs +++ b/apps/figcraft-server/src/admin_budget_tests.rs @@ -21,8 +21,18 @@ fn a_flood_of_changes_from_one_address_is_held_back_and_others_are_not() { let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap(); let (file, ..) = store.publish("Poster", &snapshot("Poster")).unwrap(); let flooder = ip("198.51.100.1"); + let start = std::time::Instant::now(); let done = (0..100).filter(|_| rename(&mut store, flooder, &file.id) == AdminReply::Done).count(); - assert_eq!(done, ADMIN_PER_MINUTE as usize, "a minute's worth, then wait"); + // The budget refills one request a second, so a slow run (a loaded test machine) lets a few more in. + let budget = ADMIN_PER_MINUTE as usize; + let refilled = start.elapsed().as_secs() as usize + 1; + assert!((budget..=budget + refilled).contains(&done), "a minute's worth, then wait: {done} done"); + // Use up whatever refilled meanwhile; the request right after is refused. + for _ in 0..refilled { + if rename(&mut store, flooder, &file.id) != AdminReply::Done { + break; + } + } assert_eq!(rename(&mut store, flooder, &file.id), AdminReply::Failed { reason: BUSY_ADMIN.into() }); // An owner token's requests share the address's budget. let hosted = store.hosted(flooder, Some(&file.id), Admin::Rename { file: file.id.clone(), name: "Mine".into() }, &|_| None).reply; From aa727500a4d9793966cc58d4bc8a46edd9abb097 Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 30 Oct 2026 03:35:12 +0700 Subject: [PATCH 57/63] M1: keep settings next to the executable in portable mode (#1320) (#1558) The settings folder comes from craft-settings' rules: FIGCRAFT_CONFIG_DIR, a `portable` file next to the executable (config/ beside it), then the platform folder every earlier version used. The Windows portable zip ships the marker, and a portable folder without preferences starts with a copy of the platform folder's. --- Cargo.lock | 52 ++++++++++++ Cargo.toml | 2 + ROADMAP.md | 3 +- apps/figcraft/Cargo.toml | 2 + apps/figcraft/src/main.rs | 2 + apps/figcraft/src/services.rs | 44 ++++++++++- apps/figcraft/src/settings_dir_tests.rs | 101 ++++++++++++++++++++++++ docs/hardware-parity.md | 4 +- docs/plugins.md | 2 +- docs/roadmap.md | 3 +- packaging/windows/package.ps1 | 5 +- 11 files changed, 214 insertions(+), 6 deletions(-) create mode 100644 apps/figcraft/src/settings_dir_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 0207d424..df1d52b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1041,6 +1041,17 @@ dependencies = [ "objc2-app-kit 0.3.2", ] +[[package]] +name = "craft-settings" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "craft-atomic", + "dirs", + "serde", + "toml_edit", +] + [[package]] name = "craft-ui" version = "0.1.0" @@ -1266,6 +1277,27 @@ dependencies = [ "crypto-common 0.2.2", ] +[[package]] +name = "dirs" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -1675,6 +1707,7 @@ version = "0.12.1" dependencies = [ "arboard", "craft-menu", + "craft-settings", "craft-update", "craft-window", "eframe", @@ -3710,6 +3743,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "orbclient" version = "0.3.55" @@ -4291,6 +4330,16 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.21", +] + [[package]] name = "regex" version = "1.13.1" @@ -5253,8 +5302,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" dependencies = [ "indexmap", + "serde_core", + "serde_spanned", "toml_datetime", "toml_parser", + "toml_writer", "winnow", ] diff --git a/Cargo.toml b/Cargo.toml index a97efa95..6a951ff3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,8 @@ craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff13 # The macOS app menu whose Quit (⌘Q) asks the app before quitting, like the window's close button # (craft-libs; the menu it replaces quits straight through `terminate:` and loses unsaved work). craft-menu = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +# Where the settings live: the suite's folder rules, portable mode included (craft-libs). +craft-settings = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } # foundation kurbo = { version = "0.13", features = ["serde"] } diff --git a/ROADMAP.md b/ROADMAP.md index e4097ad5..6f2da62e 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-29 · **Change:** minor (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); 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); 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-10-29 · **Change:** minor (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); 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); 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 @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M1, #1320: settings in portable mode. The settings folder now comes from craft-settings, the suite's rules: `FIGCRAFT_CONFIG_DIR` names a folder (set but empty: none, nothing read or written); a `portable` file next to the executable (next to the `.app` on macOS) puts the preferences, recovery copies and installed plugins in `config/` beside it; otherwise they stay in `FigCraft` in the platform folder, where every earlier version kept them, so nothing moves for an installed app. The Windows portable zip now ships the `portable` file. A portable folder without preferences yet starts with a copy of the platform folder's `state.json` (owner-only, the original left for an installed app), so a portable copy that kept its settings in `%APPDATA%` before keeps them after the update. Checked on Windows 11 with a portable copy beside an installed copy's settings: the first start took them (its name and start screen), a name changed there was kept in `config\` only, and without the marker the app read the installed settings again. Tests: `settings_dir_tests`. Numbers unchanged | | 2026-10-30 | minor | M1, #965, #1438: "Parent device is lost" at start. Both reports lost the device while wgpu compiled its indirect-call validation shaders, which it builds with every device: on Windows Direct3D 12 took another program's `dxcompiler.dll` from the search path and failed without its `dxil.dll`; Intel Macs' Metal compiler aborts on them. Direct3D 12 now compiles with FXC (part of Windows; `WGPU_DX12_COMPILER` still chooses), and the validation shaders are left out (FigCraft draws nothing indirectly; `WGPU_VALIDATION_INDIRECT_CALL` turns them back on). When the graphics still fail before the window's first frame, the app starts itself again with the next attempt, from a fixed list: on Windows Direct3D 12/OpenGL, Vulkan, OpenGL, then the first again on the power-saving GPU with WebGL2-level device limits; on Linux and FreeBSD the defaults, OpenGL, then lower requirements; on macOS the defaults, then lower requirements. The last failure's message names what was tried; the log names the backend and GPU that drew. `WGPU_BACKEND` is still the one attempt. A restarted app waits up to 5 s for its `--control` port. Checked on Windows 11 with a local build that made the first one, two, three and then all four attempts fail: each fallback opened the window (Vulkan, OpenGL, Direct3D 12 on the integrated GPU) and four failures ended with the message. Not run on the reporters' machines or on macOS. Tests: `graphics_tests`, `startup_tests`. Numbers unchanged | | 2026-10-29 | minor | M4, #1219: Thai, Lao, Khmer and Burmese text, written without spaces between words, wraps between words instead of where a line overflows (`พัฒนาซอ|ฟต์แวร์` → `พัฒนา|ซอฟต์แวร์`). `figcraft_text::breaks::word_breaks` finds the word starts inside each run of these scripts with ICU4X's line segmenter (`icu_segmenter` 2.3): its dictionaries on desktop, its LSTM models in the browser (about 0.39 MB of wasm instead of 1.86 MB, which the 24 MiB gate can't take; they split a few compounds the dictionaries keep whole). Where a run meets spaces, Latin or CJK the UAX #14 rules decide as before, and a word wider than its box still takes #1400's emergency break. Text without these scripts does no extra work. Grade unchanged. Evidence: `breaks::tests::thai_lines_break_between_words`, `lao_khmer_and_burmese_lines_break_between_words`, `word_breaks_survive_junk`, `behaviour_tests::thai_lines_break_between_words` (fails on main). | | 2026-10-30 | minor | M8, #1416: an AI agent attached over MCP shows where it works, as a person in a live session does: its avatar in its own colour in the people row, named as its comments are signed ("Claude (agent)"), and an outline with its name around the layers its last edit changed or it last selected. It goes 8 seconds after its last command and shows only in the file it works in. Display only (no undo step, not saved, never synced to a live session); the engine records it in `Session::execute_as` (`figcraft_engine::agent`). Tests: `agent_presence_tests` (engine), `live::agent` (ui-egui) | diff --git a/apps/figcraft/Cargo.toml b/apps/figcraft/Cargo.toml index 093c431d..f2ea73ba 100644 --- a/apps/figcraft/Cargo.toml +++ b/apps/figcraft/Cargo.toml @@ -20,6 +20,8 @@ figcraft-text = { workspace = true } # bespoke Wayland xdg-decoration probe. See `[workspace.dependencies]`: pinned to the unmerged # craft-libs#217 branch and must be repointed to a merged `rev` before this PR is mergeable. craft-window = { workspace = true } +# The suite's settings folder: `FIGCRAFT_CONFIG_DIR`, a portable marker, then the platform folder. +craft-settings = { workspace = true } egui = { workspace = true } eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "wayland", "x11"] } serde_json = { workspace = true } diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index 363847c8..22c84756 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -43,6 +43,8 @@ mod recovery_tests; #[cfg(test)] mod services_tests; #[cfg(test)] +mod settings_dir_tests; +#[cfg(test)] mod startup_tests; use std::sync::{Arc, Mutex}; diff --git a/apps/figcraft/src/services.rs b/apps/figcraft/src/services.rs index ca881067..d5221e9d 100644 --- a/apps/figcraft/src/services.rs +++ b/apps/figcraft/src/services.rs @@ -27,14 +27,56 @@ pub struct Desktop { last_folder: Arc>>, } +/// The settings folder's name in the platform folder, as it always was (the suite keeps existing +/// folder names), and craft-settings' app id: `FIGCRAFT_CONFIG_DIR` names another folder. +const APP_ID: &str = "FigCraft"; + +/// Where the preferences, recovery copies and installed plugins live ([`settings_dir`]). pub fn config_dir() -> Option { + settings_dir(&craft_settings::Probe { platform_config_dir: platform_dir(), ..Default::default() }) +} + +/// The platform's config folder, read from the environment as before: `%APPDATA%` on Windows, +/// so a test's own `APPDATA` keeps the app away from the user's settings. +fn platform_dir() -> Option { #[cfg(target_os = "macos")] let base = std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Application Support")); #[cfg(target_os = "windows")] let base = std::env::var_os("APPDATA").map(PathBuf::from); #[cfg(all(unix, not(target_os = "macos")))] let base = std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))); - base.map(|b| b.join("FigCraft")) + base +} + +/// The settings folder by the suite's rules (craft-settings), from what `probe` sees: +/// `FIGCRAFT_CONFIG_DIR` (set but empty: none, nothing read or written); a `portable` file next +/// to the executable (next to the `.app` on macOS), which puts them in `config/` beside it; else +/// `FigCraft` in the platform folder, where they always were. A portable folder without +/// preferences yet starts with a copy of the platform folder's, so an existing copy that becomes +/// portable keeps its settings; the original stays for an installed app. No platform folder: none. +pub(crate) fn settings_dir(probe: &craft_settings::Probe) -> Option { + use craft_settings::{Location, Portable, Rule}; + let resolved = craft_settings::paths::resolve(APP_ID, &Portable::DEFAULT, &Location::Auto, probe); + match (&resolved.rule, &resolved.dir, &probe.platform_config_dir) { + (Rule::Portable, Some(dir), Some(platform)) => seed(dir, &platform.join(APP_ID)), + // The current directory instead: settings would follow wherever the app was started. + (Rule::Fallback, ..) => return None, + _ => {} + } + resolved.dir +} + +/// Copy `from`'s preferences into the folder `to` when it has none (owner-only, as written). +fn seed(to: &Path, from: &Path) { + let (target, source) = (to.join(STATE), from.join(STATE)); + if target.exists() || !source.is_file() { + return; + } + let copied = std::fs::read(&source).and_then(|bytes| std::fs::create_dir_all(to).and_then(|()| write_private(&target, &bytes))); + match copied { + Ok(()) => log::info!("preferences: copied {} into the portable folder {}", source.display(), to.display()), + Err(e) => log::warn!("preferences: couldn't copy them into the portable folder {}: {e}", to.display()), + } } impl Desktop { diff --git a/apps/figcraft/src/settings_dir_tests.rs b/apps/figcraft/src/settings_dir_tests.rs new file mode 100644 index 00000000..c39aa91f --- /dev/null +++ b/apps/figcraft/src/settings_dir_tests.rs @@ -0,0 +1,101 @@ +//! Where the settings live (#1320): a portable copy keeps them next to the executable, taking the +//! ones it had before; without a marker they stay in the platform folder they always used. Every +//! folder here is a temporary one: the probe sees no real environment, executable or profile. + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use craft_settings::Probe; +use figcraft_engine::Prefs; + +use crate::services::{Desktop, settings_dir, write_prefs}; + +/// A fresh temporary folder with an `app` folder (the executable's) and an `appdata` folder (the +/// platform's config folder). +fn root(name: &str) -> PathBuf { + let r = std::env::temp_dir().join(format!("figcraft-settings-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&r); + std::fs::create_dir_all(r.join("app")).unwrap(); + std::fs::create_dir_all(r.join("appdata")).unwrap(); + r +} + +/// What the rules see: the executable in `root/app`, the platform folder `platform`, and +/// `FIGCRAFT_CONFIG_DIR` set to `config_dir` when given. +fn probe(root: &Path, platform: Option, config_dir: Option<&'static str>) -> Probe { + Probe { + env: Box::new(move |name| config_dir.filter(|_| name == "FIGCRAFT_CONFIG_DIR").map(OsString::from)), + exe_dir: Some(root.join("app")), + platform_config_dir: platform, + } +} + +/// Preferences with a name to recognise them by. +fn prefs(name: &str) -> Prefs { + Prefs { user_name: name.into(), ..Prefs::default() } +} + +/// The user name of the preferences the app starts with from `dir`. +fn loaded_name(dir: PathBuf) -> Option { + Desktop::load_from(Some(dir), Arc::new(Mutex::new(None))).prefs.map(|p| p.user_name) +} + +#[test] +fn without_a_marker_the_settings_stay_where_they_always_were() { + let root = root("installed"); + let old = root.join("appdata").join("FigCraft"); + write_prefs(&old, &prefs("Ada")); + let dir = settings_dir(&probe(&root, Some(root.join("appdata")), None)); + assert_eq!(dir, Some(old.clone()), "the folder every earlier version used"); + assert_eq!(loaded_name(old), Some("Ada".into()), "an existing user's preferences are read"); + assert!(!root.join("app").join("config").exists(), "nothing is written next to the executable"); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn a_portable_marker_keeps_the_settings_next_to_the_executable_and_takes_the_old_ones() { + let root = root("portable"); + let old = root.join("appdata").join("FigCraft"); + write_prefs(&old, &prefs("Ada")); + std::fs::write(root.join("app").join("portable"), "").unwrap(); + let portable = root.join("app").join("config"); + let dir = settings_dir(&probe(&root, Some(root.join("appdata")), None)); + assert_eq!(dir, Some(portable.clone())); + assert_eq!(loaded_name(portable.clone()), Some("Ada".into()), "the copy starts with the settings it had"); + assert_eq!(loaded_name(old.clone()), Some("Ada".into()), "the installed app's stay where they were"); + // Later starts keep the portable copy's own settings. + write_prefs(&portable, &prefs("Grace")); + assert_eq!(settings_dir(&probe(&root, Some(root.join("appdata")), None)), Some(portable.clone())); + assert_eq!(loaded_name(portable), Some("Grace".into()), "never overwritten by the old ones again"); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn a_new_portable_copy_starts_without_settings() { + let root = root("fresh"); + std::fs::write(root.join("app").join("portable"), "").unwrap(); + let portable = root.join("app").join("config"); + assert_eq!(settings_dir(&probe(&root, Some(root.join("appdata")), None)), Some(portable.clone())); + assert_eq!(loaded_name(portable), None, "a first run"); + // No platform folder at all: the marker still decides. + assert_eq!(settings_dir(&probe(&root, None, None)), Some(root.join("app").join("config"))); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn figcraft_config_dir_names_the_folder_and_empty_means_none() { + let root = root("env"); + std::fs::write(root.join("app").join("portable"), "").unwrap(); + let named = settings_dir(&probe(&root, Some(root.join("appdata")), Some("elsewhere"))); + assert_eq!(named, Some(PathBuf::from("elsewhere")), "ahead of the marker"); + assert_eq!(settings_dir(&probe(&root, Some(root.join("appdata")), Some(""))), None, "isolated: nothing read or written"); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn without_a_platform_folder_there_are_no_settings() { + let root = root("nowhere"); + assert_eq!(settings_dir(&probe(&root, None, None)), None, "not the current directory"); + let _ = std::fs::remove_dir_all(&root); +} diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 5bc8f89a..121a6c8e 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware and platform parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7) How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out @@ -40,6 +40,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import). | Platform | Figma | FigCraft | Notes | |---|---|---|---| | macOS | desktop app (Intel and Apple silicon) | universal `.dmg`, signed and notarized (v0.2.0) | where nearly all QA happens. Quitting ends the process once the preferences, recovery copies and live sessions are taken care of: the window's teardown crashed on Macs with a Touch Bar (#781); the Quit menu and ⌘Q ask about unsaved changes like the main window's close button (#857), and like it close the main window only: with several windows open, each quit closes one; a quit that never asked — killed or crashed — still leaves recovery copies for the next start | +| Windows x64 / arm64 | desktop app | `.msi` and portable zip for x64, x86 and arm64 | built in CI; Windows: QA pass done 2026-10-11, findings in PR. The portable zip keeps its settings in `config\` beside `figcraft.exe` (a `portable` file there), taking an installed copy's preferences on its first start (#1320) | | Windows x64 / arm64 | desktop app | `.msi` and portable zip for x64, x86 and arm64 | built in CI; Windows: QA pass done 2026-10-11, findings in PR. Draws with Direct3D 12 (FXC shaders) or OpenGL; when the graphics fail before the first frame it starts again on Vulkan, then OpenGL, then the power-saving GPU with lower limits (#965) | | Linux | none (browser only) | AppImage, `.deb`, `.rpm`, Flatpak, tarball, x86_64 and aarch64; riscv64 tarball | CI tests run here. Clipboard (also the BSDs): reads run on a helper thread that gives up after 2 s, and images copied in other apps paste under Wayland through the data-control protocol (#693). Under Wayland the app can't tell its window is minimised, so control requests that need a drawn frame may still wait there (#720). None of this run on a Linux desktop here. On a Wayland compositor without server-side decorations (GNOME) the window gets winit's Adwaita controls (#592: checked by its author in a GNOME Wayland session; KDE not run) | | FreeBSD | none | tarball | | @@ -50,6 +51,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M1, #1320: settings in portable mode. The settings folder now comes from craft-settings, the suite's rules: `FIGCRAFT_CONFIG_DIR` names a folder (set but empty: none, nothing read or written); a `portable` file next to the executable (next to the `.app` on macOS) puts the preferences, recovery copies and installed plugins in `config/` beside it; otherwise they stay in `FigCraft` in the platform folder, where every earlier version kept them, so nothing moves for an installed app. The Windows portable zip now ships the `portable` file. A portable folder without preferences yet starts with a copy of the platform folder's `state.json` (owner-only, the original left for an installed app), so a portable copy that kept its settings in `%APPDATA%` before keeps them after the update. Checked on Windows 11 with a portable copy beside an installed copy's settings: the first start took them (its name and start screen), a name changed there was kept in `config\` only, and without the marker the app read the installed settings again. Tests: `settings_dir_tests`. Numbers unchanged | | 2026-10-30 | minor | M1, #965, #1438: "Parent device is lost" at start. Both reports lost the device while wgpu compiled its indirect-call validation shaders, which it builds with every device: on Windows Direct3D 12 took another program's `dxcompiler.dll` from the search path and failed without its `dxil.dll`; Intel Macs' Metal compiler aborts on them. Direct3D 12 now compiles with FXC (part of Windows; `WGPU_DX12_COMPILER` still chooses), and the validation shaders are left out (FigCraft draws nothing indirectly; `WGPU_VALIDATION_INDIRECT_CALL` turns them back on). When the graphics still fail before the window's first frame, the app starts itself again with the next attempt, from a fixed list: on Windows Direct3D 12/OpenGL, Vulkan, OpenGL, then the first again on the power-saving GPU with WebGL2-level device limits; on Linux and FreeBSD the defaults, OpenGL, then lower requirements; on macOS the defaults, then lower requirements. The last failure's message names what was tried; the log names the backend and GPU that drew. `WGPU_BACKEND` is still the one attempt. A restarted app waits up to 5 s for its `--control` port. Checked on Windows 11 with a local build that made the first one, two, three and then all four attempts fail: each fallback opened the window (Vulkan, OpenGL, Direct3D 12 on the integrated GPU) and four failures ended with the message. Not run on the reporters' machines or on macOS. Tests: `graphics_tests`, `startup_tests`. Numbers unchanged | | 2026-10-29 | minor | Large images on the canvas (#1483): an image shown smaller than its pixels draws on the canvas from a copy scaled down to 4096 or 8192 px on its longer side, the smallest that holds what the screen shows (`figcraft_render::images::shown`), cached beside the image itself in the same 512 MB budget. Two 16 000 × 10 000 images took 640 MB each and evicted each other on every frame that drew both; as copies they take 80 MB together zoomed out and 336 MB at 8192. Moving one over the other went from about 2.1 s a frame to 11 ms zoomed to fit and 15 ms at 30% in the app (debug build). Closer than that, and for tiles, the canvas draws from the image itself, so 100% and closer stay sharp; a view of both at 100% still takes about 2 s a frame, and an image with shadows still decodes in full for them. Exports, thumbnails and every other render are unchanged. Tests: `large_image_tests`. Numbers unchanged | | 2026-10-29 | minor | macOS quit confirmation (#857): winit's default Quit menu item (⌘Q) ended the process through AppKit's `terminate:` at once, so a dirty app died without asking anything. The app now installs the craft-libs app menu (craft-menu): its Quit sends the main window a close request — the same path as its close button — so the unsaved-changes question asks before anything is lost (`windows_tests::a_quit_with_unsaved_changes_waits_for_the_answer`; checked in the app on macOS). Like the close button it closes the main window only, so with several windows open each quit closes one. A second quit before the window draws again (two quits while it is minimised, when only the app's logic runs) counts once: before, it could quit the app over another window's unsaved changes (`windows_tests::quitting_twice_before_the_window_draws_closes_it_once`). Numbers unchanged | diff --git a/docs/plugins.md b/docs/plugins.md index 2d4001bc..4eb81eed 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -180,7 +180,7 @@ with the same limits and its manifest's permissions, as any other. The desktop app keeps such plugins per user, beside its settings (`Plugins/` in FigCraft's settings folder: `%APPDATA%\FigCraft` on Windows, `~/Library/Application Support/FigCraft` on macOS, -`~/.config/FigCraft` elsewhere), each in a folder of its own named by a hash of its id, with its +`~/.config/FigCraft` elsewhere; `config/` beside the executable in a portable copy), each in a folder of its own named by a hash of its id, with its module and its assets. The CLI and the MCP server install for the session only. **Updating.** When the directory has a newer version than the one installed, the plugin's row diff --git a/docs/roadmap.md b/docs/roadmap.md index f08a455f..2109aa4c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (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); 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); 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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-10-29 · **Change:** minor (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); 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); 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M1, #1320: settings in portable mode: a `portable` file next to the executable keeps the settings, recovery copies and plugins in `config/` beside it (the Windows portable zip ships one), starting with the platform folder's preferences; the folder comes from craft-settings' rules, and without a marker it is the one every earlier version used. Tests: `settings_dir_tests` | | 2026-10-30 | minor | M1, #965, #1438: "Parent device is lost" at start: Direct3D 12 compiles with FXC, no device compiles wgpu's indirect-validation shaders, and when the graphics still fail before the first frame the app starts again with the next of a fixed list of attempts (Windows: Vulkan, OpenGL; Linux and FreeBSD: OpenGL; then lower requirements on the power-saving GPU); the last failure names what was tried. Tests: `graphics_tests`, `startup_tests` | | 2026-10-29 | minor | M4, #1219: Thai, Lao, Khmer and Burmese text, written without spaces between words, wraps between words instead of where a line overflows (`พัฒนาซอ|ฟต์แวร์` → `พัฒนา|ซอฟต์แวร์`). `figcraft_text::breaks::word_breaks` finds the word starts inside each run of these scripts with ICU4X's line segmenter (`icu_segmenter` 2.3): its dictionaries on desktop, its LSTM models in the browser (about 0.39 MB of wasm instead of 1.86 MB, which the 24 MiB gate can't take; they split a few compounds the dictionaries keep whole). Where a run meets spaces, Latin or CJK the UAX #14 rules decide as before, and a word wider than its box still takes #1400's emergency break. Text without these scripts does no extra work. Grade unchanged. Evidence: `breaks::tests::thai_lines_break_between_words`, `lao_khmer_and_burmese_lines_break_between_words`, `word_breaks_survive_junk`, `behaviour_tests::thai_lines_break_between_words` (fails on main). | | 2026-10-30 | minor | M8, #1416: an attached MCP agent's presence (avatar in the people row, outline and name tag on the layers it last edited or selected), set by `Session::execute_as` and gone 8 seconds after its last command; display only, never synced. Tests: `agent_presence_tests` (engine), `live::agent` (ui-egui) | diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index ecf92345..80f35eec 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -189,7 +189,10 @@ if ($env:CRAFT_FONTS_DIR) { if (Test-Path $lic) { Copy-Item $lic (Join-Path $Portable "OFL-$($_.Name).txt") } } } -# files go to FigCraftData\ next to the exe instead of %APPDATA% (#228; see app_dirs.rs). +# A `portable` file next to figcraft.exe keeps its settings, recovery copies and plugins in +# config\ beside it instead of %APPDATA%\FigCraft (#1320). The first start copies the +# preferences an installed FigCraft kept there, if any. +New-Item -ItemType File -Force -Path (Join-Path $Portable 'portable') | Out-Null $Zip = Join-Path $Dist "figcraft-$Version-windows-$Arch-portable.zip" Remove-Item -Force $Zip -ErrorAction SilentlyContinue Compress-Archive -Path $Portable -DestinationPath $Zip From d890de92c484b544a4df9f3217b119c505b9b4e1 Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 30 Oct 2026 04:01:07 +0700 Subject: [PATCH 58/63] M8: SVG import reads color() and color-mix() colours (#1550) (#1566) The SVG parser reads neither, so fills came in black and strokes were dropped without a note. They are resolved before parsing: color(srgb|display-p3 ...) converted to the document's profile, color-mix(in srgb|oklab|oklch, ...) evaluated as CSS Color 5 defines it. Other spaces are approximated with a note; unreadable calls become a mid grey with a note. Bounded length, nesting and count. --- ROADMAP.md | 3 +- crates/svg/src/import.rs | 9 + crates/svg/src/import/css_color.rs | 314 +++++++++++++++++++++++ crates/svg/src/import/css_color_tests.rs | 163 ++++++++++++ docs/file-format-parity.md | 5 +- docs/roadmap.md | 3 +- 6 files changed, 493 insertions(+), 4 deletions(-) create mode 100644 crates/svg/src/import/css_color.rs create mode 100644 crates/svg/src/import/css_color_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 6f2da62e..838144fd 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-29 · **Change:** minor (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); 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); 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-10-29 · **Change:** minor (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); 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); 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 @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M8, #1550: SVG import reads CSS colour functions its SVG parser doesn't (it painted them black, or left strokes out, without a word): `color(srgb …)` and `color(display-p3 …)`, with alpha, converted to the document's profile, and `color-mix(in srgb|oklab|oklch, …)`, evaluated at import (percentages, alpha and nested mixes as CSS Color 5 has them), in colour attributes, `style` attributes and ` + + + "#; + let (nodes, notes) = import_in(ColorProfile::Srgb, body); + assert!(notes.iter().all(|n| !n.contains("the colour `")), "{notes:?}"); + let rect = nodes.get(1).unwrap(); + assert_eq!(rgba8(rect.fills.first().and_then(Paint::solid_color)), Some([255, 0, 0, 255])); + assert_eq!(rgba8(rect.strokes.first().and_then(Paint::solid_color)), Some([128, 0, 128, 255])); + let stops = match nodes.get(2).and_then(|n| n.fills.first()).map(|p| &p.kind) { + Some(figcraft_color::PaintKind::Gradient(g)) => g.stops.clone(), + k => panic!("{k:?}"), + }; + assert_eq!(stops.first().map(|s| s.color.to_rgba8()), Some([0, 255, 0, 255])); + // Text and other attributes keep their words. + let svg = r#"color(srgb 1 0 0) mycolor(1)"#; + let (out, _) = resolve(svg.as_bytes(), ColorProfile::Srgb).unwrap(); + assert_eq!( + String::from_utf8(out).unwrap(), + r##"color(srgb 1 0 0) mycolor(1)"## + ); +} + +#[test] +fn what_isnt_read_gets_a_note_and_a_grey_not_black() { + let grey = Some([128, 128, 128, 255]); + for call in [ + "color(srgb a b c)", + "color(srgb 1 0)", + "color(from red srgb r g b)", + "color-mix(in srgb, red)", + "color-mix(srgb, red, blue)", + "color-mix(in srgb, red 150%, blue)", + "color-mix(in srgb, red 0%, blue 0%)", + "color-mix(in srgb, currentcolor, blue)", + "color(srgb 1 0 0", + ] { + let (c, notes) = fill(call); + assert_eq!(rgba8(c), grey, "{call}"); + assert!(notes.iter().any(|n| n.contains("the colour `")), "{call}: {notes:?}"); + } + // Spaces and mixes it doesn't know: close, with a note saying how. + let (c, notes) = fill("color(rec2020 0 0 1)"); + assert_eq!(rgba8(c), Some([0, 0, 255, 255])); + assert!(notes.iter().any(|n| n.contains("rec2020") && n.contains("sRGB")), "{notes:?}"); + let (c, notes) = fill("color-mix(in hsl, red, blue)"); + assert_eq!(rgba8(c), Some([128, 0, 128, 255])); + assert!(notes.iter().any(|n| n.contains("mixed in sRGB")), "{notes:?}"); +} + +#[test] +fn garbage_and_deep_nesting_are_bounded() { + // Nested deeper than the limit: a note, no stack overflow. + let deep = (0..50).fold("red".to_string(), |acc, _| format!("color-mix(in srgb, {acc}, blue)")); + let (c, notes) = fill(&deep); + assert!(c.is_some() && notes.iter().any(|n| n.contains("nested")), "{notes:?}"); + // Many calls, unclosed ones, odd bytes: each attribute is read, nothing panics. + let many = "color(srgb 0 0 1) ".repeat(5000); + assert!( + resolve(format!(r#""#).as_bytes(), ColorProfile::Srgb).is_some() + ); + let mut seed = 7u64; + let pieces = ["color(", "color-mix(", "in ", "srgb", "oklch", ")", "(", ",", " ", "%", "/", "50", "1e40", "NaN", "é", "#fff", "none"]; + for _ in 0..500 { + let junk: String = (0..12) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + pieces[(seed % pieces.len() as u64) as usize] + }) + .collect(); + let svg = format!(r#""#); + let _ = resolve(svg.as_bytes(), ColorProfile::Srgb); + let mut d = Document::new("t"); + let p = d.first_page().unwrap(); + let _ = import_svg_with_notes(&mut d, svg.as_bytes(), p, 0.0, 0.0, "x"); + } + // Nothing to do: the document comes back as it was. + assert!(resolve(br#""#, ColorProfile::Srgb).is_none()); + assert!(resolve(b"\xff\xfecolor(", ColorProfile::Srgb).is_none()); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index fc779ba0..be665e55 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-10-29 · **Change:** minor (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-10-30 · **Change:** minor (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 @@ -30,7 +30,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft | Notes | Hours | |---|---|---|---|---| -| SVG | editable vectors | **yes**, editable | corpus of 21 synthetic files with hand-computed expected pixels, structure and round-trip checks; gradients, patterns as image fills, clip paths, masks, shadow/blur filters, blend modes, text as text layers; other filters dropped with a warning; text on one path as text on a path (on several paths as outlines); a pattern's tile is drawn once per scale however many layers use it, and its layers count in the import's 100,000-layer budget; fuzzed | 2–4 (filters) | +| SVG | editable vectors | **yes**, editable | corpus of 21 synthetic files with hand-computed expected pixels, structure and round-trip checks; gradients, `color()` (sRGB, Display P3) and `color-mix()` (sRGB, OKLab, OKLCH) colours, patterns as image fills, clip paths, masks, shadow/blur filters, blend modes, text as text layers; other filters dropped with a warning; text on one path as text on a path (on several paths as outlines); a pattern's tile is drawn once per scale however many layers use it, and its layers count in the import's 100,000-layer budget; fuzzed | 2–4 (filters) | | PNG, JPG | yes | yes | embedded ICC profiles (PNG `iCCP`, JPG APP2) and PNG `sRGB` / `gAMA` / `cHRM` chunks convert the image into the file's colour space; no profile (or CMYK, damaged): taken as sRGB | — | | GIF | yes (animated) | **yes**, animated | the file is kept as is; the canvas and PNG, JPG and PDF exports show the first frame (SVG exports embed the file, so viewers that animate GIFs animate it), the presenter and the preview play it (frames decoded on first play: at most 500 frames and 128 MB, past that the rest don't play; always loops) | — | | WebP | yes | yes | embedded ICC profile honoured | — | @@ -184,6 +184,7 @@ source paint coordinates. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M8, #1550: SVG import reads CSS colour functions its SVG parser doesn't (it painted them black, or left strokes out, without a word): `color(srgb …)` and `color(display-p3 …)`, with alpha, converted to the document's profile, and `color-mix(in srgb|oklab|oklch, …)`, evaluated at import (percentages, alpha and nested mixes as CSS Color 5 has them), in colour attributes, `style` attributes and `