diff --git a/ROADMAP.md b/ROADMAP.md index 12a206a..403a4a8 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-13 · **Change:** minor (core editor leftovers: delays on mouse triggers and manual overlay offsets, wrapped-line alignment; core editor leftovers: wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; 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; 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; Windows QA pass with eight fixes; 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%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (core editor leftovers: a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; core editor leftovers: delays on mouse triggers and manual overlay offsets, wrapped-line alignment; core editor leftovers: wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; 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; 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; Windows QA pass with eight fixes; 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%) · **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 @@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-13** · Frames and groups, a one-step convert (M1): `object.convertToGroup` and `object.convertToFrame` (the Design panel's type menu on a frame or a group) change the layer in place, its layers staying where they are on the page. A frame turned into a group loses what only a frame has (auto layout, with the positions it gave kept and fill children fixed; fills, strokes, corner radius, clipping, layout grids, guides, scrolling) and takes its layers' box; a group turned into a frame keeps its box, with no fill and no clipping. Instances, main components, layers inside instances and empty frames are refused with the reason, all or nothing; one undo step. Numbers unchanged. - **2026-10-13** · Prototyping, delays on mouse triggers and manual overlay offsets (M5): mouse enter, leave, down and up wait `delayMs` before their actions run (once while waiting; dropped when the screen or overlay they were on goes away; a pointer that leaves early doesn't cancel, the documentation is silent), and a manual overlay sits at `offset: [x, y]` from the top-left of the layer that opens it (without one, right below it, as before). Delay and X/Y fields in the interaction editor, the player on its own clock, REST import (`delay`, `overlayRelativePosition`), `.fig` import and export (the export's field for mouse delays is unchecked against other software). Numbers unchanged. - **2026-10-13** · 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. - **2026-10-13** · `.fig` export: instance sizes, variables, styles (gap 1): instances write what they change from @@ -299,6 +300,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Frames and groups, a one-step convert (M1): `object.convertToGroup` and `object.convertToFrame` (the Design panel's type menu on a frame or a group) change the layer in place, its layers staying where they are on the page. A frame turned into a group loses what only a frame has (auto layout, with the positions it gave kept and fill children fixed; fills, strokes, corner radius, clipping, layout grids, guides, scrolling) and takes its layers' box; a group turned into a frame keeps its box, with no fill and no clipping. Instances, main components, layers inside instances and empty frames are refused with the reason, all or nothing; one undo step. Numbers unchanged. | | 2026-10-13 | minor | Prototyping, delays on mouse triggers and manual overlay offsets (M5): mouse enter, leave, down and up wait `delayMs` before their actions run (once while waiting; dropped when the screen or overlay they were on goes away; a pointer that leaves early doesn't cancel, the documentation is silent), and a manual overlay sits at `offset: [x, y]` from the top-left of the layer that opens it (without one, right below it, as before). Delay and X/Y fields in the interaction editor, the player on its own clock, REST import (`delay`, `overlayRelativePosition`), `.fig` import and export (the export's field for mouse delays is unchecked against other software). 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. | | 2026-10-13 | minor | `.fig` export (gap 1): instance sizes (resized, filling an auto layout, scaled), variables with modes and bindings, shared styles and variable-width strokes are written and read back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged | diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs index ae4acc9..13693d5 100644 --- a/crates/engine/src/cmd/structure.rs +++ b/crates/engine/src/cmd/structure.rs @@ -16,6 +16,20 @@ pub fn specs() -> Vec { spec!("object.ungroup", "Ungroup selection", "{ids?}", true, ungroup), spec!("object.frameSelection", "Frame selection", "{ids?, name?}", true, |s, p| wrap(s, p, Wrap::Frame)), spec!("object.removeFrame", "Remove frame", "{ids?}", true, ungroup), + spec!( + "object.convertToGroup", + "Convert to group", + "{ids?} (frames become groups in place, their layers staying where they are; what only a frame has goes: auto layout, fills, strokes, corner radius, clipping, layout grids, guides and scrolling. Groups are left alone; instances, main components, layers inside instances and empty frames are refused)", + true, + |s, p| convert(s, p, "object.convertToGroup", false) + ), + spec!( + "object.convertToFrame", + "Convert to frame", + "{ids?} (groups become frames of the same box in place, their layers staying where they are: no fill, not clipping. Frames are left alone; instances, main components and layers inside instances are refused)", + true, + |s, p| convert(s, p, "object.convertToFrame", true) + ), spec!( "object.wrapInSection", "Wrap in new section", @@ -174,6 +188,60 @@ fn wrap(s: &mut Session, p: &Value, w: Wrap) -> Result { Ok(json!({"id": gid})) } +/// Turn frames into groups or groups into frames in place (same id, name, layers, effects, +/// opacity, exports and interactions), the layers inside staying where they are on the page. +/// Nothing changes unless every layer can convert. +fn convert(s: &mut Session, p: &Value, cmd: &'static str, to_frame: bool) -> Result { + let ids = req_targets(cmd, s, p)?; + let doc = s.doc_mut()?; + let mut todo = Vec::new(); + for i in ids { + let n = doc.get(i)?; + let refuse = |why: &str| Err(bad(cmd, format!("\"{}\" {why}", n.name))); + match (&n.kind, to_frame) { + (NodeKind::Instance { .. }, _) => return refuse("is an instance: its layers come from its main component (detach it first)"), + (NodeKind::Component { .. }, _) => return refuse("is a main component, which stays a frame"), + (NodeKind::Frame | NodeKind::Group, _) if crate::components::owning_instance(doc, i).is_some() => { + return refuse("is inside an instance: edit its main component, or detach the instance"); + } + (NodeKind::Frame, false) if n.children.is_empty() => return refuse("is empty: a group needs layers"), + (NodeKind::Frame, false) | (NodeKind::Group, true) => todo.push(i), + (NodeKind::Frame | NodeKind::Group, _) => {} + _ => return refuse("is neither a frame nor a group"), + } + } + for i in &todo { + if to_frame { + // The group's box as it is; nothing painted or clipped, so the look stays. + let n = doc.get_mut(*i)?; + n.kind = NodeKind::Frame; + n.clip = false; + n.fills.clear(); + n.strokes.clear(); + continue; + } + let n = doc.get_mut(*i)?; + n.kind = NodeKind::Group; + (n.layout, n.clip, n.overflow) = (None, false, proto::Overflow::None); + n.fills.clear(); + n.strokes.clear(); + n.grids.clear(); + n.guides.clear(); + n.corners = Corners::default(); + (n.styles.fill, n.styles.stroke, n.styles.grid) = (None, None, None); + // Variables bound to what went would bring it back. + n.bound.retain(|k, _| matches!(k.as_str(), "opacity" | "visible")); + let kids = n.children.clone(); + for k in kids { + // Out of the flow there is nothing to fill or to be absolute against. + doc.get_mut(k)?.child_layout.absolute = false; + } + figcraft_layout::coherent_sizing(doc, *i); + doc.fit_to_children(*i)?; + } + Ok(json!({"ids": todo})) +} + fn ungroup(s: &mut Session, p: &Value) -> Result { let ids = req_targets("object.ungroup", s, p)?; let doc = s.doc_mut()?; diff --git a/crates/engine/src/convert_container_tests.rs b/crates/engine/src/convert_container_tests.rs new file mode 100644 index 0000000..7c25a62 --- /dev/null +++ b/crates/engine/src/convert_container_tests.rs @@ -0,0 +1,150 @@ +//! Frames and groups converted into each other in place: the layers stay where they are on the +//! page, what only a frame has goes with it, what can't convert is refused, one undo step. + +use serde_json::{Value, json}; + +use crate::params::id_of; +use crate::{EngineError, Session}; +use figcraft_doc::{NodeId, NodeKind, Sizing}; + +fn id(v: Value) -> NodeId { + id_of(&v["id"]).unwrap() +} + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) +} + +fn rect(s: &mut Session, parent: NodeId, x: f64, y: f64) -> NodeId { + id(ex(s, "shape.rectangle", json!({"parent": parent, "x": x, "y": y, "width": 40, "height": 20}))) +} + +/// A layer's box on the page: `[x0, y0, x1, y1]`, rounded. +fn page_box(s: &Session, n: NodeId) -> [f64; 4] { + let b = s.doc().unwrap().world_bounds(n).unwrap(); + [b.x0, b.y0, b.x1, b.y1].map(|v| (v * 1000.0).round() / 1000.0) +} + +fn kind(s: &Session, n: NodeId) -> NodeKind { + s.doc().unwrap().node(n).unwrap().kind.clone() +} + +#[test] +fn a_frame_becomes_a_group_around_its_layers() { + let mut s = Session::new(); + let f = id(ex( + &mut s, + "frame.create", + json!({"x": 100, "y": 50, "width": 300, "height": 200, "name": "Card", "props": {"fill": "#FF0000", "cornerRadius": 8}}), + )); + let (a, b) = (rect(&mut s, f, 20.0, 30.0), rect(&mut s, f, 120.0, 90.0)); + ex(&mut s, "node.set", json!({"id": f, "props": {"opacity": 0.5}})); + let before = [page_box(&s, a), page_box(&s, b)]; + let r = ex(&mut s, "object.convertToGroup", json!({"ids": [f]})); + assert_eq!(r["ids"], json!([f])); + let n = s.doc().unwrap().node(f).unwrap().clone(); + assert!(matches!(n.kind, NodeKind::Group)); + assert_eq!((n.name.as_str(), n.opacity, n.children.clone()), ("Card", 0.5, vec![a, b]), "the same layer: name, opacity, layers"); + assert!(n.fills.is_empty() && n.strokes.is_empty() && !n.clip && n.corners.uniform() == Some(0.0), "what only a frame has is gone"); + assert_eq!([page_box(&s, a), page_box(&s, b)], before, "the layers stay where they are"); + // A group's box is its layers': (120, 80) to (260, 160). + assert_eq!(page_box(&s, f), [120.0, 80.0, 260.0, 160.0]); + ex(&mut s, "edit.undo", json!({})); + let n = s.doc().unwrap().node(f).unwrap(); + assert!(matches!(n.kind, NodeKind::Frame) && n.fills.len() == 1 && n.clip, "one undo step brings the frame back"); + assert_eq!(page_box(&s, f), [100.0, 50.0, 400.0, 250.0]); +} + +#[test] +fn auto_layout_is_dropped_and_positions_kept() { + let mut s = Session::new(); + let props = json!({"layout": "horizontal", "padding": 10, "gap": 10}); + let f = id(ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "props": props}))); + let (a, b) = (rect(&mut s, f, 0.0, 0.0), rect(&mut s, f, 0.0, 0.0)); + ex(&mut s, "node.set", json!({"id": b, "props": {"sizingH": "fill", "sizingV": "fill"}})); + // Laid out: a at (10, 10) 40 × 20, b fills (60, 10) to (190, 90). + let before = [page_box(&s, a), page_box(&s, b)]; + assert_eq!(before, [[10.0, 10.0, 50.0, 30.0], [60.0, 10.0, 190.0, 90.0]]); + ex(&mut s, "object.convertToGroup", json!({"ids": [f]})); + let doc = s.doc().unwrap(); + assert!(doc.node(f).unwrap().layout.is_none()); + assert_eq!([page_box(&s, a), page_box(&s, b)], before, "the layout's positions and sizes are kept"); + let cl = &doc.node(b).unwrap().child_layout; + assert_eq!((cl.sizing_h, cl.sizing_v), (Sizing::Fixed, Sizing::Fixed), "nothing to fill in a group"); + // And back: a plain frame of the group's box, the layers still in place. + ex(&mut s, "object.convertToFrame", json!({"ids": [f]})); + let n = s.doc().unwrap().node(f).unwrap(); + assert!(matches!(n.kind, NodeKind::Frame) && n.layout.is_none() && !n.clip && n.fills.is_empty()); + assert_eq!((page_box(&s, f), [page_box(&s, a), page_box(&s, b)]), ([10.0, 10.0, 190.0, 90.0], before)); +} + +#[test] +fn a_rotated_group_becomes_a_frame_in_place() { + let mut s = Session::new(); + let page = s.page().unwrap(); + let (a, b) = (rect(&mut s, page, 10.0, 10.0), rect(&mut s, page, 80.0, 60.0)); + let g = id(ex(&mut s, "object.group", json!({"ids": [a, b]}))); + ex(&mut s, "node.set", json!({"id": g, "props": {"rotation": 30}})); + let before = [page_box(&s, a), page_box(&s, b), page_box(&s, g)]; + ex(&mut s, "object.convertToFrame", json!({"ids": [g]})); + assert!(matches!(kind(&s, g), NodeKind::Frame)); + assert_eq!([page_box(&s, a), page_box(&s, b), page_box(&s, g)], before); + // The layers now sit in the frame's space: a at its origin. + let c = s.doc().unwrap().node(a).unwrap().transform.as_coeffs(); + assert!(c[4].abs() < 1e-6 && c[5].abs() < 1e-6, "{c:?}"); +} + +#[test] +fn what_cannot_convert_is_refused_and_nothing_changes() { + let mut s = Session::new(); + let page = s.page().unwrap(); + let f = id(ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100}))); + let inner = id(ex(&mut s, "frame.create", json!({"x": 10, "y": 10, "width": 50, "height": 50, "parent": f}))); + rect(&mut s, inner, 0.0, 0.0); + let comp = id(ex(&mut s, "component.create", json!({"ids": [f]}))); + let inst = id(ex(&mut s, "instance.create", json!({"component": comp, "x": 300, "y": 0}))); + let inst_inner = s.doc().unwrap().node(inst).unwrap().children[0]; + let empty = id(ex(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 50, "height": 50}))); + let loose = rect(&mut s, page, 500.0, 0.0); + let ok_frame = id(ex(&mut s, "frame.create", json!({"x": 0, "y": 500, "width": 50, "height": 50}))); + rect(&mut s, ok_frame, 5.0, 5.0); + // (layer, what the message says) + for (layer, says) in [ + (comp, "main component"), + (inst, "is an instance"), + (inst_inner, "inside an instance"), + (empty, "is empty"), + (loose, "neither a frame nor a group"), + ] { + for cmd in ["object.convertToGroup", "object.convertToFrame"] { + if layer == empty && cmd == "object.convertToFrame" { + continue; + } + // Together with a frame that could convert: all or nothing. + let e = s.execute(cmd, &json!({"ids": [ok_frame, layer]})).unwrap_err().to_string(); + assert!(e.contains(says), "{cmd} on {layer}: {e}"); + assert!(matches!(kind(&s, ok_frame), NodeKind::Frame), "{cmd}: nothing converts when one can't"); + } + } + // Already what was asked for: left alone, not an error. + assert_eq!(ex(&mut s, "object.convertToFrame", json!({"ids": [ok_frame]}))["ids"], json!([])); + // A frame inside a main component converts (its instances follow their main). + ex(&mut s, "object.convertToGroup", json!({"ids": [inner]})); + assert!(matches!(kind(&s, inner), NodeKind::Group)); +} + +#[test] +fn junk_parameters_are_refused_not_fatal() { + let junk = [json!(null), json!(true), json!(-1), json!(1e308), json!("x"), json!([[1]]), json!({"a": 1}), json!([0, 999_999, "7"])]; + for cmd in ["object.convertToGroup", "object.convertToFrame"] { + for j in &junk { + let mut s = Session::new(); + let f = id(ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 50, "height": 50}))); + rect(&mut s, f, 0.0, 0.0); + for p in [json!({"ids": j}), json!({"id": j}), j.clone()] { + let r = s.execute(cmd, &p); + assert!(!matches!(r, Err(EngineError::Internal { .. })), "{cmd} {p}: {r:?}"); + } + } + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 823509c..d059266 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1366,6 +1366,8 @@ mod components_behaviour_tests; #[cfg(test)] mod components_scale_tests; #[cfg(test)] +mod convert_container_tests; +#[cfg(test)] mod cursor_tests; #[cfg(test)] mod dev_resources_tests; diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 52d93b7..996602a 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1780,6 +1780,8 @@ status = "draft — needs native review" "node.reparent" = "Mover capa dentro de" "node.set" = "Establecer propiedades" "object.batchRename" = "Cambiar nombre en lote" +"object.convertToFrame" = "Convertir en marco" +"object.convertToGroup" = "Convertir en grupo" "object.flipH" = "Voltear horizontalmente" "object.flipV" = "Voltear verticalmente" "object.frameSelection" = "Enmarcar selección" diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index a53ee48..7dbfa54 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -458,6 +458,18 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { None => tr("Mixed").into(), }; let is_frame = sel.len() == 1 && sel.first().and_then(|i| doc.node(*i)).is_some_and(|n| matches!(n.kind, NodeKind::Frame)); + // Frames and groups convert into each other from the type menu. + let is_group = !sel.is_empty() && sel.iter().all(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Group))); + let kind_items = |app: &mut FigApp, ui: &mut Ui, frame: bool| { + for (to_frame, label, cmd) in [(true, tr("Frame"), "object.convertToFrame"), (false, tr("Group"), "object.convertToGroup")] { + if menu_check(ui, to_frame == frame, label, "") { + if to_frame != frame { + app.run(cmd, json!({"ids": sel})); + } + ui.close(); + } + } + }; let w = ui.available_width(); let (r, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover()); let lr = Rect::from_min_size(pos2(r.min.x + 8.0, r.center().y - 12.0), vec2(120.0, 24.0)); @@ -466,13 +478,18 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { ui.painter().rect_filled(lr, CornerRadius::same(5), t.hover); } ui.painter().text(pos2(lr.min.x + 8.0, lr.center().y), Align2::LEFT_CENTER, &label, semibold(13.0), t.text); - if is_frame { + if is_frame || is_group { icons::paint( ui.painter(), Rect::from_center_size(pos2(lr.min.x + 18.0 + label.len() as f32 * 6.5, lr.center().y), vec2(10.0, 10.0)), Icon::Chevron, t.icon_secondary, ); + } + if is_group { + menu_popup(&resp).width(180.0).show(|ui| kind_items(app, ui, false)); + } + if is_frame { let sr = Rect::from_min_size(pos2(lr.max.x + 4.0, r.center().y - 12.0), vec2(24.0, 24.0)); let sresp = ui.interact(sr, ui.id().with("swap_orient"), Sense::click()).tip(tr("Swap orientation")); if sresp.hovered() { @@ -485,6 +502,8 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let ids: Vec = sel.to_vec(); menu_popup(&resp).width(260.0).show(|ui| { egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| { + kind_items(app, ui, true); + ui.separator(); let mut cat = ""; for (c, name, pw, ph) in figcraft_engine::cmd::create::FRAME_PRESETS { if *c != cat { diff --git a/docs/mcp.md b/docs/mcp.md index a3ff4ce..9e93757 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -97,6 +97,8 @@ Or in a project's `.mcp.json`: align to the top; nested auto layout frames use their first child's baseline). - `wrap` with `crossGap` (alias `rowGap`) for the gap between wrapped rows (horizontal) or columns (vertical layouts wrap top to bottom into new columns). + - `object.convertToGroup` / `object.convertToFrame` turn a frame into a group or back in place + (layers stay put; a group has no auto layout, fill, stroke, corner radius, clipping or grids). - `contentAlign` (wrapping layouts): `auto` (default) packs the lines `crossGap` apart at the start, centre or end `crossAlign` names (lines of nothing but fill children stretch instead); `spaceBetween` puts the free space between the lines (one line, or no free space: like `auto`). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3bd6e8f..be8c6d0 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-13 · **Change:** minor (core editor leftovers: delays on mouse triggers and manual overlay offsets, wrapped-line alignment; core editor leftovers: 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-13 · **Change:** minor (core editor leftovers: a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; core editor leftovers: delays on mouse triggers and manual overlay offsets, wrapped-line alignment; core editor leftovers: 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 @@ -101,7 +101,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 5 | Paint | 7 | 90% | 71% | Video fills, wide-gamut canvas, HEIC images; plus darker in PDF (behaviour suite from the documentation, pass-through and isolating groups, clipping frames that don't isolate, plus darker, animated GIF fills, TIFF images and embedded image colour profiles landed) | 2–5 | | 6 | Strokes | 3 | 95% | 75% | Stroke QA with real files (colour styles on strokes, stroke weight and per-side weight variables, brush strokes landed, see 20) | 1–2 | | 7 | Effects | 4 | 100% | 75% | Glass and progressive blur tuned side by side; inner shadow and background blur on groups (behaviour suite from the documentation, 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 (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 | +| 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 | | 11 | Components & variants | 9 | 96% | 72% | Real (not synthetic) libraries, `.fig` libraries, component playground (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 | @@ -625,6 +625,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Frames and groups, a one-step convert (M1): `object.convertToGroup` and `object.convertToFrame` (the Design panel's type menu on a frame or a group) change the layer in place, its layers staying where they are on the page. A frame turned into a group loses what only a frame has (auto layout, with the positions it gave kept and fill children fixed; fills, strokes, corner radius, clipping, layout grids, guides, scrolling) and takes its layers' box; a group turned into a frame keeps its box, with no fill and no clipping. Instances, main components, layers inside instances and empty frames are refused with the reason, all or nothing; one undo step. Numbers unchanged. | | 2026-10-13 | minor | Prototyping, delays on mouse triggers and manual overlay offsets (M5): mouse enter, leave, down and up wait `delayMs` before their actions run (once while waiting; dropped when the screen or overlay they were on goes away; a pointer that leaves early doesn't cancel, the documentation is silent), and a manual overlay sits at `offset: [x, y]` from the top-left of the layer that opens it (without one, right below it, as before). Delay and X/Y fields in the interaction editor, the player on its own clock, REST import (`delay`, `overlayRelativePosition`), `.fig` import and export (the export's field for mouse delays is unchecked against other software). 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. | | 2026-10-13 | minor | Starter templates and a first run that explains itself: eight templates built in code through the command layer (mobile app, landing page, dashboard, design system starter, wireframe kit, social post sizes, presentation slides, icon set starter; original and synthetic, deterministic, about 10–190 ms each to build), `template.list` and `file.newFromTemplate` for the app, CLI and MCP, File ▸ New from template…, a Templates tab on the home screen (cards with thumbnails rendered from the documents, search, kind filter, keyboard and screen-reader names) in place of the Community placeholder, a row of templates on Recents while there are no recent files, a Getting started card that can be closed and comes back from Help ▸ Getting started (`help.gettingStarted`), and a first run that opens on the home screen. The Drafts placeholder is gone (the app has no drafts folder). Essentials: discoverability ×0.92 → ×0.94 (templates and first-run tips exist; no Community files, no tour, no user evidence), 77.8% × 0.92 × 0.94 × 0.88 = 59.2%, ~57% → ~59%; essentials hours ~60–110 h → ~55–100 h (the onboarding and templates item is done); menu breadth stays 211/211 (the two new menu items are FigCraft's own and not counted); every other number unchanged |