From a2a0f80252f3ceeb616b17c48f3ea6c812e0c2fa Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 13 Oct 2026 01:04:32 +0700 Subject: [PATCH] M2: auto layout: wrapped-line alignment --- ROADMAP.md | 4 +- crates/doc/src/props.rs | 16 ++ crates/engine/src/al_content_align_tests.rs | 115 ++++++++++++ crates/engine/src/cmd/props.rs | 7 +- crates/engine/src/codegen.rs | 15 ++ crates/engine/src/components.rs | 5 +- crates/engine/src/devinspect.rs | 3 +- crates/engine/src/lib.rs | 2 + crates/format/src/fig/export.rs | 17 +- crates/format/src/fig/map.rs | 3 + crates/format/src/rest/props.rs | 10 +- crates/layout/src/behaviour_tests.rs | 1 + crates/layout/src/content_align_tests.rs | 194 ++++++++++++++++++++ crates/layout/src/lib.rs | 34 +++- crates/mcp/src/tools.rs | 2 +- crates/ui-egui/src/layout_panel.rs | 52 ++++-- docs/auto-layout-parity.md | 5 +- docs/mcp.md | 3 + docs/target-app-parity.md | 5 +- 19 files changed, 455 insertions(+), 38 deletions(-) create mode 100644 crates/engine/src/al_content_align_tests.rs create mode 100644 crates/layout/src/content_align_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 961ee4a..f5e833a 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 (`.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: 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** · 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 their main (size, name, paints) as an override addressed by the main's id, what follows for their layers as derived data and a scale factor when scaled as a whole; variable collections, modes, variables and bindings, @@ -297,6 +298,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 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 | | 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 ~57% → ~59% (discoverability ×0.92 → ×0.94), its hours ~60–110 h → ~55–100 h; other numbers unchanged | | 2026-10-12 | minor | Your online files (M9): a file put on a server without its key stays its owner's after its tab closes or the app quits. The app records each one with the preferences (server, id, name, owner token, the owner's links, the codes others join with; at most 200; the token never in a command result, `ui.inspect`, a log or debug output) and Home ▸ **Online** lists them: open one as its owner (a new tab, or its tab comes to the front), Start / Stop sharing, Copy code / Copy link, Rename on the server, Remove from server (confirmed), Forget (the file stays on the server). Nothing is asked of a server until the section opens; then one request per server (`Admin owned`, a compatible addition within protocol version 2) says which files are still there, and a server that is offline or older leaves the list as recorded. Tabs of files in a live session carry a mark (shared, or open only to you), toasts about a file that isn't in front name it, and after a crash a recorded file reopens from its server as its owner, its recovery copy kept until its unsent changes are back on the server (or it is opened beside the file). `collab.files`, `collab.openFile`, `collab.forgetFile`; `collab.manageFile` takes its server and token from the record and gains `share` / `unshare`. The draft catalogs (French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese) translate the 71 strings and 5 command labels they lacked and the new ones. Numbers unchanged | diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index e071b80..4f6491c 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -1015,6 +1015,19 @@ pub enum CrossAlign { Baseline, } +/// How the lines of a wrapping layout (rows of a horizontal one, columns of a vertical one) share +/// the free space across the flow. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum ContentAlign { + /// Packed, `cross_gap` apart, at the start, centre or end the cross alignment names (lines of + /// nothing but fill children stretch instead). + #[default] + Auto, + /// The free space goes between the lines (one line, or no free space: like `Auto`). + SpaceBetween, +} + /// Auto layout container properties (a frame with auto layout). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] @@ -1029,6 +1042,8 @@ pub struct AutoLayout { pub padding: [f64; 4], pub main_align: MainAlign, pub cross_align: CrossAlign, + /// Wrapping layouts: how the lines are spread across the flow. + pub content_align: ContentAlign, /// Grid mode: column and row counts (rows grow to fit the children). pub grid_columns: u32, pub grid_rows: u32, @@ -1052,6 +1067,7 @@ impl Default for AutoLayout { padding: [10.0; 4], main_align: MainAlign::Min, cross_align: CrossAlign::Min, + content_align: ContentAlign::Auto, grid_columns: 2, grid_rows: 2, column_tracks: Vec::new(), diff --git a/crates/engine/src/al_content_align_tests.rs b/crates/engine/src/al_content_align_tests.rs new file mode 100644 index 0000000..0723114 --- /dev/null +++ b/crates/engine/src/al_content_align_tests.rs @@ -0,0 +1,115 @@ +//! Wrapped-line alignment through commands: the `contentAlign` prop and its alias, undo, the CSS +//! and inspect output, instance overrides, the REST and `.fig` mappings, and junk values. + +use serde_json::{Value, json}; + +use crate::params::id_of; +use crate::{EngineError, Session}; +use figcraft_doc::{ContentAlign, NodeId}; + +fn id(v: Value) -> NodeId { + id_of(&v["id"]).unwrap() +} + +/// A 100 × 100 wrapping frame of three 40 × 20 rectangles (gap 10, row gap 5): two rows. +fn wrapping(s: &mut Session) -> (NodeId, Vec) { + let props = json!({"layout": "horizontal", "wrap": true, "padding": 0, "gap": 10, "crossGap": 5}); + let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100, "props": props})).unwrap()); + let kids = (0..3).map(|_| id(s.execute("shape.rectangle", &json!({"parent": f, "x": 0, "y": 0, "width": 40, "height": 20})).unwrap())).collect(); + (f, kids) +} + +fn ys(s: &Session, kids: &[NodeId]) -> Vec { + kids.iter().map(|k| s.doc().unwrap().node(*k).unwrap().transform.as_coeffs()[5]).collect() +} + +fn content(s: &Session, f: NodeId) -> ContentAlign { + s.doc().unwrap().node(f).unwrap().layout.as_ref().unwrap().content_align +} + +#[test] +fn content_align_spreads_the_rows_and_undoes() { + let mut s = Session::new(); + let (f, kids) = wrapping(&mut s); + assert_eq!(ys(&s, &kids), [0.0, 0.0, 25.0]); + s.execute("node.set", &json!({"id": f, "props": {"contentAlign": "spaceBetween"}})).unwrap(); + assert_eq!(ys(&s, &kids), [0.0, 0.0, 80.0]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((content(&s, f), ys(&s, &kids)), (ContentAlign::Auto, vec![0.0, 0.0, 25.0])); + // The file format's name and spelling work too; packed rows follow the cross alignment. + s.execute("node.set", &json!({"id": f, "props": {"counterAxisAlignContent": "SPACE_BETWEEN"}})).unwrap(); + assert_eq!(content(&s, f), ContentAlign::SpaceBetween); + s.execute("node.set", &json!({"id": f, "props": {"contentAlign": "auto", "crossAlign": "max"}})).unwrap(); + assert_eq!(ys(&s, &kids), [55.0, 55.0, 80.0]); +} + +#[test] +fn css_and_inspect_name_the_lines() { + let mut s = Session::new(); + let (f, _) = wrapping(&mut s); + let css = |s: &mut Session| s.execute("inspect.code", &json!({"id": f})).unwrap().to_string(); + assert!(css(&mut s).contains("align-content: flex-start;"), "{}", css(&mut s)); + s.execute("node.set", &json!({"id": f, "props": {"crossAlign": "center"}})).unwrap(); + assert!(css(&mut s).contains("align-content: center;")); + s.execute("node.set", &json!({"id": f, "props": {"contentAlign": "spaceBetween"}})).unwrap(); + assert!(css(&mut s).contains("align-content: space-between;")); + let rows = s.execute("inspect.properties", &json!({"id": f})).unwrap().to_string(); + assert!(rows.contains("Auto (space between)"), "{rows}"); + // One line has no lines to align. + s.execute("node.set", &json!({"id": f, "props": {"wrap": false}})).unwrap(); + assert!(!css(&mut s).contains("align-content")); +} + +#[test] +fn an_instance_keeps_its_own_line_alignment() { + let mut s = Session::new(); + let (f, _) = wrapping(&mut s); + let comp = id(s.execute("component.create", &json!({"ids": [f]})).unwrap()); + let inst = id(s.execute("instance.create", &json!({"component": comp, "x": 200, "y": 0})).unwrap()); + s.execute("node.set", &json!({"id": inst, "props": {"contentAlign": "spaceBetween"}})).unwrap(); + // A change to the main reaches the instance; its own alignment stays. + s.execute("node.set", &json!({"id": comp, "props": {"padding": 2}})).unwrap(); + assert_eq!((content(&s, comp), content(&s, inst)), (ContentAlign::Auto, ContentAlign::SpaceBetween)); +} + +#[test] +fn rest_and_fig_carry_the_setting() { + let frame = |extra: Value| { + let mut f = json!({"id": "1:2", "name": "Wrap", "type": "FRAME", "relativeTransform": [[1, 0, 0], [0, 1, 0]], "size": {"x": 100, "y": 100}, + "layoutMode": "HORIZONTAL", "layoutWrap": "WRAP", "children": []}); + f.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + json!({"name": "File", "document": {"id": "0:0", "type": "DOCUMENT", "children": [{"id": "0:1", "name": "Page", "type": "CANVAS", "children": [f]}]}}) + }; + let read = |file: &Value| { + let (doc, _) = figcraft_format::rest::import(file, &figcraft_format::rest::no_images).unwrap(); + doc + }; + let of = |doc: &figcraft_doc::Document| doc.nodes().find(|n| n.name == "Wrap").unwrap().layout.as_ref().unwrap().content_align; + assert_eq!(of(&read(&frame(json!({})))), ContentAlign::Auto); + assert_eq!(of(&read(&frame(json!({"counterAxisAlignContent": "AUTO"})))), ContentAlign::Auto); + let spread = read(&frame(json!({"counterAxisAlignContent": "SPACE_BETWEEN"}))); + assert_eq!(of(&spread), ContentAlign::SpaceBetween); + // Written to `.fig` and read back, both values survive. + for doc in [spread, read(&frame(json!({})))] { + let (bytes, _) = figcraft_format::fig::export(&doc, None).unwrap(); + let (back, _) = figcraft_format::fig::import(&bytes, "x").unwrap(); + assert_eq!(of(&back), of(&doc)); + } +} + +#[test] +fn junk_values_are_refused_not_fatal() { + let junk = + [json!(null), json!(true), json!(-1), json!(1e308), json!(""), json!("sideways"), json!([1, 2]), json!({"a": 1}), json!("x".repeat(10_000))]; + for k in ["contentAlign", "counterAxisAlignContent"] { + for j in &junk { + let mut s = Session::new(); + let (f, kids) = wrapping(&mut s); + for target in [f, kids[0]] { + let r = s.execute("node.set", &json!({"id": target, "props": {k: j}})); + assert!(!matches!(r, Err(EngineError::Internal { .. })), "{k} = {j}: {r:?}"); + } + assert_eq!(content(&s, f), ContentAlign::Auto, "{k} = {j}"); + } + } +} diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 5bbd335..ebf9dd0 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -16,7 +16,7 @@ pub const KEYS: &str = "name, visible, locked, opacity (0-1), blend, x, y (relat fills (paints or colours), fill (colour), strokes, stroke (colour), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \ strokeCap / startCap / endCap, strokeJoin, miterAngle (degrees: sharper joins bevel; or miterLimit as a ratio), dashes [..], widthProfile (preset uniform|wedge|taper|eye|rounded|pinch, [[t, scale], ...] or null), brush (preset id, {preset, spacing, sizeJitter, rotationJitter, scatter, seed} or null), cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \ clip, isMask, layout (null|horizontal|vertical|grid|{...}), gap, crossGap, padding (n or [t,r,b,l]), paddingTop/Right/Bottom/Left, \ -mainAlign (min|center|max, or the auto gaps spaceBetween|spaceAround|spaceEvenly), crossAlign (min|center|max|baseline: first text baselines, horizontal layouts), wrap (rows of a horizontal layout, columns of a vertical one), strokesIncluded (the frame's inside stroke takes up space), reverseZ (canvas stacking: true = first on top), \ +mainAlign (min|center|max, or the auto gaps spaceBetween|spaceAround|spaceEvenly), crossAlign (min|center|max|baseline: first text baselines, horizontal layouts), contentAlign (wrapped lines: auto = packed where crossAlign says | spaceBetween), wrap (rows of a horizontal layout, columns of a vertical one), strokesIncluded (the frame's inside stroke takes up space), reverseZ (canvas stacking: true = first on top), \ columnGap / rowGap (aliases of gap / crossGap), gridColumns, gridRows, columnTracks / rowTracks ([120 | \"1fr\" | \"auto\" | {sizing, value}]), \ gridColumn / gridRow (0-based cell anchor, null = auto), gridColumnSpan, gridRowSpan, gridAlignH / gridAlignV (a grid child's alignment in its cell: min|center|max, auto/null = the frame's), sizingH / sizingV (fixed|hug|fill), \ absolute (ignore auto layout; follows constraints), minWidth, maxWidth, minHeight, maxHeight (null removes), constraints {horizontal, vertical}, grids, exports, characters, fontFamily, fontStyle, \ @@ -472,6 +472,9 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map { ensure_layout(doc.get_mut(id)?).cross_align = enum_of(v).ok_or_else(|| bad(cmd, "crossAlign: min|center|max|baseline"))? } + "contentAlign" | "counterAxisAlignContent" => { + ensure_layout(doc.get_mut(id)?).content_align = enum_of(v).ok_or_else(|| bad(cmd, "contentAlign: auto|spaceBetween"))? + } "wrap" => ensure_layout(doc.get_mut(id)?).wrap = v.as_bool().unwrap_or(false), "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; @@ -795,7 +798,7 @@ pub fn override_group(k: &str) -> &'static str { "width" | "height" | "w" | "h" | "sizingH" | "sizingV" => "size", "gap" | "itemSpacing" | "columnGap" | "crossGap" | "counterAxisSpacing" | "rowGap" => "gap", "padding" | "paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft" | "paddingHorizontal" | "paddingVertical" => "padding", - "mainAlign" | "primaryAxisAlignItems" | "crossAlign" | "counterAxisAlignItems" => "alignment", + "mainAlign" | "primaryAxisAlignItems" | "crossAlign" | "counterAxisAlignItems" | "contentAlign" | "counterAxisAlignContent" => "alignment", "grids" | "layoutGrids" | "gridStyle" => "grids", "exports" | "exportSettings" => "exports", "interactions" => "interactions", diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs index 41bf7bb..3f6c42b 100644 --- a/crates/engine/src/codegen.rs +++ b/crates/engine/src/codegen.rs @@ -343,6 +343,21 @@ pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bo CrossAlign::Baseline => "baseline", }; put("align-items", align.into()); + if l.wrap { + // The lines: spread, or packed where the items align (baseline: at the start). + let spread = l.content_align == ContentAlign::SpaceBetween; + put( + "align-content", + if spread { + "space-between" + } else if align == "baseline" { + "flex-start" + } else { + align + } + .into(), + ); + } } if matches!(n.kind, NodeKind::Ellipse { .. }) { put("border-radius", "50%".into()); diff --git a/crates/engine/src/components.rs b/crates/engine/src/components.rs index 8145ea8..c6bf041 100644 --- a/crates/engine/src/components.rs +++ b/crates/engine/src/components.rs @@ -375,7 +375,7 @@ pub(crate) fn copy_group(from: &Node, to: &mut Node, g: &str) { match g { "padding" => b.padding = a.padding, "gap" => (b.gap, b.cross_gap) = (a.gap, a.cross_gap), - _ => (b.main_align, b.cross_align) = (a.main_align, a.cross_align), + _ => (b.main_align, b.cross_align, b.content_align) = (a.main_align, a.cross_align, a.content_align), } } } @@ -1003,7 +1003,7 @@ pub fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> { if x.gap != y.gap || x.cross_gap != y.cross_gap { out.push("gap"); } - if x.main_align != y.main_align || x.cross_align != y.cross_align { + if x.main_align != y.main_align || x.cross_align != y.cross_align || x.content_align != y.content_align { out.push("alignment"); } } @@ -1680,6 +1680,7 @@ pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Opti let settings = |x: &Node| { x.layout.clone().map(|mut l| { (l.gap, l.cross_gap, l.padding, l.main_align, l.cross_align) = (0.0, 0.0, [0.0; 4], MainAlign::Min, CrossAlign::Min); + l.content_align = ContentAlign::Auto; l }) }; diff --git a/crates/engine/src/devinspect.rs b/crates/engine/src/devinspect.rs index 7ddca48..e94e88b 100644 --- a/crates/engine/src/devinspect.rs +++ b/crates/engine/src/devinspect.rs @@ -550,7 +550,8 @@ fn auto_layout(doc: &Document, n: &Node, l: &AutoLayout) -> Section { rows.push(prop("Gap", gap).token(variable(doc, n, "gap"))); if l.wrap { let label = if l.mode == LayoutMode::Vertical { "Column gap" } else { "Row gap" }; - rows.push(prop(label, num(l.cross_gap)).token(variable(doc, n, "crossGap"))); + let spread = l.content_align == ContentAlign::SpaceBetween; + rows.push(if spread { prop(label, "Auto (space between)") } else { prop(label, num(l.cross_gap)).token(variable(doc, n, "crossGap")) }); } } let [t, r, b, lft] = l.padding; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b651364..fd9936f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1318,6 +1318,8 @@ mod a11y_keys_tests; #[cfg(test)] mod al_behaviour_tests; #[cfg(test)] +mod al_content_align_tests; +#[cfg(test)] mod al_drag_tests; #[cfg(test)] mod al_keys_tests; diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index 9bb1a8a..46d9007 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -25,9 +25,10 @@ use figcraft_doc::proto::{ Action, Direction, Easing, GAMEPAD_BUTTONS, Interaction, KeyMods, Overflow, OverlayPosition, ScrollPosition, Transition, Trigger, }; use figcraft_doc::{ - AutoLayout, BoolOp, CrossAlign, Document, EffectKind, ExportSize, GridAlign, GridKind, HConstraint, LayoutMode, LetterSpacing, LineHeight, - ListStyle, MainAlign, MaskType, Node, NodeId, NodeKind, NoiseMode, PropDef, Sizing, StrokeAlign, StrokeCap, StrokeJoin, StyleKind, TextAlignH, - TextAlignV, TextAutoResize, TextCase, TextData, TextDecoration, TextStyle, VConstraint, VariableType, VariableValue, VectorNetwork, WindingRule, + AutoLayout, BoolOp, ContentAlign, CrossAlign, Document, EffectKind, ExportSize, GridAlign, GridKind, HConstraint, LayoutMode, LetterSpacing, + LineHeight, ListStyle, MainAlign, MaskType, Node, NodeId, NodeKind, NoiseMode, PropDef, Sizing, StrokeAlign, StrokeCap, StrokeJoin, StyleKind, + TextAlignH, TextAlignV, TextAutoResize, TextCase, TextData, TextDecoration, TextStyle, VConstraint, VariableType, VariableValue, VectorNetwork, + WindingRule, }; use serde_json::Value; @@ -76,6 +77,7 @@ enum StackJustify MIN=0 CENTER=1 MAX=2 SPACE_EVENLY=3 SPACE_BETWEEN=4 SPACE_AROU enum StackCounterAlign MIN=0 CENTER=1 MAX=2 STRETCH=3 AUTO=4 BASELINE=5 enum StackSize FIXED=0 RESIZE_TO_FIT=1 enum StackWrap NO_WRAP=0 WRAP=1 +enum StackCounterAlignContent AUTO=0 SPACE_BETWEEN=1 enum StackPositioning AUTO=0 ABSOLUTE=1 enum EffectType INNER_SHADOW=0 DROP_SHADOW=1 FOREGROUND_BLUR=2 BACKGROUND_BLUR=3 NOISE=7 GRAIN=6 GLASS=8 enum BlurOpType NORMAL=0 PROGRESSIVE=1 @@ -166,7 +168,7 @@ message PrototypeStartingPoint string name=1, string position=3 message OverlayBackgroundAppearance OverlayBackgroundType backgroundType=1, Color backgroundColor=2 message GUIDPositionMapEntry GUID id=1, string position=2 message GUIDPositionMap GUIDPositionMapEntry[] entries=1 -message NodeChange GUID guid=1, NodePhase phase=2, ParentIndex parentIndex=3, NodeType type=4, string name=5, bool visible=6, bool locked=7, float opacity=8, BlendMode blendMode=9, Vector size=11, Matrix transform=12, bool mask=16, MaskType maskType=317, float backgroundOpacity=19, bool backgroundEnabled=15, bool internalOnly=142, string description=318, StyleType styleType=163, string sortPosition=320, StyleId styleIdForFill=332, StyleId styleIdForStrokeFill=333, StyleId styleIdForText=334, StyleId styleIdForEffect=335, StyleId styleIdForGrid=336, VariableDataMap variableConsumptionMap=307, VariableModeBySetMap variableModeBySetMap=316, VariableSetMode[] variableSetModes=312, VariableSetID variableSetID=313, VariableResolvedDataType variableResolvedType=314, VariableDataValues variableDataValues=315, VariableScope[] variableScopes=353, DocumentColorProfile documentColorProfile=349, Paint[] fillPaints=38, Paint[] strokePaints=39, float strokeWeight=26, StrokeAlign strokeAlign=29, StrokeJoin strokeJoin=31, StrokeCap strokeCap=30, float[] dashPattern=13, float miterLimit=25, bool borderStrokeWeightsIndependent=299, float borderTopWeight=295, float borderRightWeight=298, float borderBottomWeight=296, float borderLeftWeight=297, Effect[] effects=43, float cornerRadius=20, float cornerSmoothing=160, bool rectangleCornerRadiiIndependent=149, float rectangleTopLeftCornerRadius=145, float rectangleTopRightCornerRadius=146, float rectangleBottomRightCornerRadius=148, float rectangleBottomLeftCornerRadius=147, ConstraintType horizontalConstraint=28, ConstraintType verticalConstraint=37, bool frameMaskDisabled=115, bool resizeToFit=117, bool isStateGroup=225, bool sectionContentsHidden=283, Color backgroundColor=50, StackMode stackMode=105, float stackSpacing=107, float stackCounterSpacing=324, StackWrap stackWrap=323, float stackHorizontalPadding=209, float stackVerticalPadding=210, float stackPaddingRight=233, float stackPaddingBottom=234, StackJustify stackPrimaryAlignItems=230, StackAlign stackCounterAlignItems=231, StackSize stackPrimarySizing=229, StackSize stackCounterSizing=221, bool bordersTakeSpace=294, bool stackReverseZIndex=271, float gridColumnGap=438, float gridRowGap=437, GUIDPositionMap gridColumns=436, GUIDPositionMap gridRows=435, float stackChildPrimaryGrow=232, StackCounterAlign stackChildAlignSelf=236, StackPositioning stackPositioning=269, OptionalVector minSize=325, OptionalVector maxSize=326, GUID gridColumnAnchor=440, GUID gridRowAnchor=439, uint gridColumnSpan=442, uint gridRowSpan=441, LayoutGrid[] layoutGrids=47, ExportSettings[] exportSettings=45, TextData textData=42, FontName fontName=41, float fontSize=21, Number lineHeight=40, Number letterSpacing=165, TextCase textCase=34, TextDecoration textDecoration=35, TextAlignHorizontal textAlignHorizontal=32, TextAlignVertical textAlignVertical=33, TextAutoResize textAutoResize=46, TextTruncation textTruncation=280, int maxLines=351, float paragraphSpacing=23, float paragraphIndent=22, Hyperlink hyperlink=223, bool autoRename=14, bool fontVariantCommonLigatures=127, bool fontVariantContextualLigatures=128, string fontVersion=202, uint textUserLayoutVersion=203, uint textExplicitLayoutVersion=396, uint textBidiVersion=279, float textTracking=27, bool textDecorationSkipInk=412, EmojiImageSet emojiImageSet=391, uint styleID=49, Path[] fillGeometry=51, Path[] strokeGeometry=52, VectorData vectorData=48, VectorMirror handleMirroring=44, uint count=10, float starInnerScale=24, ArcData arcData=195, BooleanOperation booleanOperation=36, string symbolDescription=144, VariableWidthPoint[] variableWidthPoints=447, PluginData[] pluginData=204, SymbolData symbolData=113, NodeChange[] derivedSymbolData=125, GUID overriddenSymbolID=143, ComponentPropDef[] componentPropDefs=266, ComponentPropAssignment[] componentPropAssignments=268, VariableDataMap parameterConsumptionMap=445, GUIDPath guidPath=111, PrototypeInteraction[] prototypeInteractions=226, PrototypeStartingPoint prototypeStartingPoint=249, ScrollDirection scrollDirection=159, ScrollBehavior scrollBehavior=186, OverlayPositionType overlayPositionType=198, OverlayBackgroundInteraction overlayBackgroundInteraction=200, OverlayBackgroundAppearance overlayBackgroundAppearance=201 +message NodeChange GUID guid=1, NodePhase phase=2, ParentIndex parentIndex=3, NodeType type=4, string name=5, bool visible=6, bool locked=7, float opacity=8, BlendMode blendMode=9, Vector size=11, Matrix transform=12, bool mask=16, MaskType maskType=317, float backgroundOpacity=19, bool backgroundEnabled=15, bool internalOnly=142, string description=318, StyleType styleType=163, string sortPosition=320, StyleId styleIdForFill=332, StyleId styleIdForStrokeFill=333, StyleId styleIdForText=334, StyleId styleIdForEffect=335, StyleId styleIdForGrid=336, VariableDataMap variableConsumptionMap=307, VariableModeBySetMap variableModeBySetMap=316, VariableSetMode[] variableSetModes=312, VariableSetID variableSetID=313, VariableResolvedDataType variableResolvedType=314, VariableDataValues variableDataValues=315, VariableScope[] variableScopes=353, DocumentColorProfile documentColorProfile=349, Paint[] fillPaints=38, Paint[] strokePaints=39, float strokeWeight=26, StrokeAlign strokeAlign=29, StrokeJoin strokeJoin=31, StrokeCap strokeCap=30, float[] dashPattern=13, float miterLimit=25, bool borderStrokeWeightsIndependent=299, float borderTopWeight=295, float borderRightWeight=298, float borderBottomWeight=296, float borderLeftWeight=297, Effect[] effects=43, float cornerRadius=20, float cornerSmoothing=160, bool rectangleCornerRadiiIndependent=149, float rectangleTopLeftCornerRadius=145, float rectangleTopRightCornerRadius=146, float rectangleBottomRightCornerRadius=148, float rectangleBottomLeftCornerRadius=147, ConstraintType horizontalConstraint=28, ConstraintType verticalConstraint=37, bool frameMaskDisabled=115, bool resizeToFit=117, bool isStateGroup=225, bool sectionContentsHidden=283, Color backgroundColor=50, StackMode stackMode=105, float stackSpacing=107, float stackCounterSpacing=324, StackWrap stackWrap=323, StackCounterAlignContent stackCounterAlignContent=343, float stackHorizontalPadding=209, float stackVerticalPadding=210, float stackPaddingRight=233, float stackPaddingBottom=234, StackJustify stackPrimaryAlignItems=230, StackAlign stackCounterAlignItems=231, StackSize stackPrimarySizing=229, StackSize stackCounterSizing=221, bool bordersTakeSpace=294, bool stackReverseZIndex=271, float gridColumnGap=438, float gridRowGap=437, GUIDPositionMap gridColumns=436, GUIDPositionMap gridRows=435, float stackChildPrimaryGrow=232, StackCounterAlign stackChildAlignSelf=236, StackPositioning stackPositioning=269, OptionalVector minSize=325, OptionalVector maxSize=326, GUID gridColumnAnchor=440, GUID gridRowAnchor=439, uint gridColumnSpan=442, uint gridRowSpan=441, LayoutGrid[] layoutGrids=47, ExportSettings[] exportSettings=45, TextData textData=42, FontName fontName=41, float fontSize=21, Number lineHeight=40, Number letterSpacing=165, TextCase textCase=34, TextDecoration textDecoration=35, TextAlignHorizontal textAlignHorizontal=32, TextAlignVertical textAlignVertical=33, TextAutoResize textAutoResize=46, TextTruncation textTruncation=280, int maxLines=351, float paragraphSpacing=23, float paragraphIndent=22, Hyperlink hyperlink=223, bool autoRename=14, bool fontVariantCommonLigatures=127, bool fontVariantContextualLigatures=128, string fontVersion=202, uint textUserLayoutVersion=203, uint textExplicitLayoutVersion=396, uint textBidiVersion=279, float textTracking=27, bool textDecorationSkipInk=412, EmojiImageSet emojiImageSet=391, uint styleID=49, Path[] fillGeometry=51, Path[] strokeGeometry=52, VectorData vectorData=48, VectorMirror handleMirroring=44, uint count=10, float starInnerScale=24, ArcData arcData=195, BooleanOperation booleanOperation=36, string symbolDescription=144, VariableWidthPoint[] variableWidthPoints=447, PluginData[] pluginData=204, SymbolData symbolData=113, NodeChange[] derivedSymbolData=125, GUID overriddenSymbolID=143, ComponentPropDef[] componentPropDefs=266, ComponentPropAssignment[] componentPropAssignments=268, VariableDataMap parameterConsumptionMap=445, GUIDPath guidPath=111, PrototypeInteraction[] prototypeInteractions=226, PrototypeStartingPoint prototypeStartingPoint=249, ScrollDirection scrollDirection=159, ScrollBehavior scrollBehavior=186, OverlayPositionType overlayPositionType=198, OverlayBackgroundInteraction overlayBackgroundInteraction=200, OverlayBackgroundAppearance overlayBackgroundAppearance=201 struct Blob byte[] bytes message Message MessageType type=1, uint sessionID=2, NodeChange[] nodeChanges=4, Blob[] blobs=6 "; @@ -1404,7 +1406,12 @@ impl<'d> Exporter<'d> { } self.tracks.insert(key, (cols, rows)); } else { - f.extend([("stackSpacing", fl(l.gap)), ("stackCounterSpacing", fl(l.cross_gap))]); + let spread = l.content_align == ContentAlign::SpaceBetween; + f.extend([ + ("stackSpacing", fl(l.gap)), + ("stackCounterSpacing", fl(l.cross_gap)), + ("stackCounterAlignContent", e(if spread { "SPACE_BETWEEN" } else { "AUTO" })), + ]); } } diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 9b21651..9074c4e 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -584,6 +584,9 @@ impl<'a> Mapper<'a> { if v.str("stackWrap") == Some("WRAP") { o.insert("layoutWrap".into(), json!("WRAP")); } + if v.str("stackCounterAlignContent") == Some("SPACE_BETWEEN") { + o.insert("counterAxisAlignContent".into(), json!("SPACE_BETWEEN")); + } let (h, vert) = (v.num("stackHorizontalPadding").unwrap_or(0.0), v.num("stackVerticalPadding").unwrap_or(0.0)); o.insert("paddingLeft".into(), json!(h)); o.insert("paddingTop".into(), json!(vert)); diff --git a/crates/format/src/rest/props.rs b/crates/format/src/rest/props.rs index 54b6443..67ec806 100644 --- a/crates/format/src/rest/props.rs +++ b/crates/format/src/rest/props.rs @@ -4,9 +4,9 @@ use figcraft_doc::color::{BlendMode, Color, ColorStop, Gradient, GradientKind, ImageFilters, ImagePaint, ImageScaleMode, Paint, PaintKind}; use figcraft_doc::geom::Affine; use figcraft_doc::{ - AutoLayout, Blur, BlurType, CellAlign, ChildLayout, Constraints, CrossAlign, Effect, EffectKind, ExportFormat, ExportSetting, ExportSize, Glass, - GridAlign, GridKind, HConstraint, LayoutGrid, LayoutMode, MainAlign, Noise, NoiseMode, Shadow, Sizing, StrokeAlign, StrokeCap, StrokeJoin, - StrokeProps, Texture, VConstraint, WidthPoint, WidthProfile, + AutoLayout, Blur, BlurType, CellAlign, ChildLayout, Constraints, ContentAlign, CrossAlign, Effect, EffectKind, ExportFormat, ExportSetting, + ExportSize, Glass, GridAlign, GridKind, HConstraint, LayoutGrid, LayoutMode, MainAlign, Noise, NoiseMode, Shadow, Sizing, StrokeAlign, StrokeCap, + StrokeJoin, StrokeProps, Texture, VConstraint, WidthPoint, WidthProfile, }; use serde_json::Value; @@ -424,6 +424,10 @@ pub(super) fn auto_layout(v: &Value) -> Option { Some("BASELINE") => CrossAlign::Baseline, _ => CrossAlign::Min, }, + content_align: match text(v, "counterAxisAlignContent") { + Some("SPACE_BETWEEN") => ContentAlign::SpaceBetween, + _ => ContentAlign::Auto, + }, grid_columns: count("gridColumnCount", 2), grid_rows: count("gridRowCount", 2), strokes_included: flag(v, "strokesIncludedInLayout").unwrap_or(false), diff --git a/crates/layout/src/behaviour_tests.rs b/crates/layout/src/behaviour_tests.rs index 36f5454..bba20c5 100644 --- a/crates/layout/src/behaviour_tests.rs +++ b/crates/layout/src/behaviour_tests.rs @@ -127,6 +127,7 @@ fn build(c: &Case<'_>) -> (Document, NodeId, Vec, FakeMeasure) { row_tracks: f.rows.to_vec(), strokes_included: f.strokes_included, reverse_z: false, + content_align: ContentAlign::Auto, }); (n.child_layout.sizing_h, n.child_layout.sizing_v) = f.sizing; (n.child_layout.min_w, n.child_layout.min_h) = f.min; diff --git a/crates/layout/src/content_align_tests.rs b/crates/layout/src/content_align_tests.rs new file mode 100644 index 0000000..921c80c --- /dev/null +++ b/crates/layout/src/content_align_tests.rs @@ -0,0 +1,194 @@ +//! Wrapped lines across the flow (rows of a horizontal layout, columns of a vertical one): packed +//! where the cross alignment says, stretched when every child fills, or with the free space +//! between them. Each case's comment is the hand-computed derivation. + +use super::*; +use figcraft_doc::{Document, NodeKind}; + +use ContentAlign::{Auto, SpaceBetween as Between}; +use CrossAlign::{Center, Max, Min}; + +struct Case<'a> { + rule: &'static str, + vertical: bool, + size: (f64, f64), + hug_cross: bool, + pad: [f64; 4], + cross_gap: f64, + cross: CrossAlign, + content: ContentAlign, + /// Children `(w, h)`; `fill` makes them all fill across the flow. + kids: &'a [(f64, f64)], + fill: bool, + /// Each child's `(x, y, w, h)` in the frame. + want: &'a [(f64, f64, f64, f64)], +} + +const CASE: Case<'static> = Case { + rule: "", + vertical: false, + size: (100.0, 100.0), + hug_cross: false, + pad: [0.0; 4], + cross_gap: 5.0, + cross: Min, + content: Auto, + kids: &[(40.0, 20.0), (40.0, 20.0), (40.0, 20.0)], + fill: false, + want: &[], +}; + +fn run(c: &Case<'_>) -> Vec<(f64, f64, f64, f64)> { + let mut d = Document::new("t"); + let page = d.first_page().unwrap(); + let f = d.create(page, NodeKind::Frame, "F", 0.0, 0.0, c.size.0, c.size.1).unwrap(); + let n = d.get_mut(f).unwrap(); + n.layout = Some(AutoLayout { + mode: if c.vertical { LayoutMode::Vertical } else { LayoutMode::Horizontal }, + wrap: true, + gap: 10.0, + cross_gap: c.cross_gap, + padding: c.pad, + cross_align: c.cross, + content_align: c.content, + ..AutoLayout::default() + }); + if c.hug_cross { + *(if c.vertical { &mut n.child_layout.sizing_h } else { &mut n.child_layout.sizing_v }) = Sizing::Hug; + } + let kids: Vec = c + .kids + .iter() + .map(|(w, h)| { + let k = d.create(f, NodeKind::Rectangle, "K", 0.0, 0.0, *w, *h).unwrap(); + if c.fill { + let cl = &mut d.get_mut(k).unwrap().child_layout; + *(if c.vertical { &mut cl.sizing_h } else { &mut cl.sizing_v }) = Sizing::Fill; + } + k + }) + .collect(); + layout_frame(&mut d, f, &NoMeasure); + kids.iter() + .map(|k| { + let n = d.node(*k).unwrap(); + let t = n.transform.as_coeffs(); + (t[4], t[5], n.width, n.height) + }) + .collect() +} + +#[test] +fn wrapped_lines_across_the_flow() { + // Unless a case says otherwise: a 100 × 100 frame, three 40 × 20 children, gap 10, so two + // rows (40 + 10 + 40 = 90 fits, the third wraps) of 20 each, 5 apart: 45 of 100, 55 free. + let cases = [ + Case { rule: "packed at the start", want: &[(0.0, 0.0, 40.0, 20.0), (50.0, 0.0, 40.0, 20.0), (0.0, 25.0, 40.0, 20.0)], ..CASE }, + Case { + rule: "packed in the centre: 55 / 2 above", + cross: Center, + want: &[(0.0, 27.5, 40.0, 20.0), (50.0, 27.5, 40.0, 20.0), (0.0, 52.5, 40.0, 20.0)], + ..CASE + }, + Case { + rule: "packed at the end: 55 above", + cross: Max, + want: &[(0.0, 55.0, 40.0, 20.0), (50.0, 55.0, 40.0, 20.0), (0.0, 80.0, 40.0, 20.0)], + ..CASE + }, + Case { + rule: "space between: the gap value gives way, 100 - 40 between two rows", + content: Between, + want: &[(0.0, 0.0, 40.0, 20.0), (50.0, 0.0, 40.0, 20.0), (0.0, 80.0, 40.0, 20.0)], + ..CASE + }, + Case { + rule: "space between three rows: (100 - 60) / 2 = 20 between", + size: (50.0, 100.0), + content: Between, + want: &[(0.0, 0.0, 40.0, 20.0), (0.0, 40.0, 40.0, 20.0), (0.0, 80.0, 40.0, 20.0)], + ..CASE + }, + Case { + rule: "space between, items still align in their row: rows of 40 and 20, the short ones centred in row one", + content: Between, + cross: Center, + kids: &[(40.0, 20.0), (40.0, 40.0), (40.0, 20.0)], + want: &[(0.0, 10.0, 40.0, 20.0), (50.0, 0.0, 40.0, 40.0), (0.0, 80.0, 40.0, 20.0)], + ..CASE + }, + Case { + rule: "space between inside padding: inner height 80, 40 between", + pad: [10.0, 0.0, 10.0, 0.0], + content: Between, + want: &[(0.0, 10.0, 40.0, 20.0), (50.0, 10.0, 40.0, 20.0), (0.0, 70.0, 40.0, 20.0)], + ..CASE + }, + Case { + rule: "space between with no free space: rows touch and sit where the alignment says (30 - 40 = -10, half above)", + size: (100.0, 30.0), + content: Between, + cross: Center, + want: &[(0.0, -5.0, 40.0, 20.0), (50.0, -5.0, 40.0, 20.0), (0.0, 15.0, 40.0, 20.0)], + ..CASE + }, + Case { + rule: "space between with one row: like packed, the items align in the frame", + size: (200.0, 100.0), + content: Between, + cross: Max, + want: &[(0.0, 80.0, 40.0, 20.0), (50.0, 80.0, 40.0, 20.0), (100.0, 80.0, 40.0, 20.0)], + ..CASE + }, + Case { + rule: "space between in a frame hugging its height: nothing to share, rows stay 5 apart", + hug_cross: true, + content: Between, + want: &[(0.0, 0.0, 40.0, 20.0), (50.0, 0.0, 40.0, 20.0), (0.0, 25.0, 40.0, 20.0)], + ..CASE + }, + Case { + rule: "rows of nothing but fill children stretch: (100 - 5) / 2 = 47.5 each", + fill: true, + want: &[(0.0, 0.0, 40.0, 47.5), (50.0, 0.0, 40.0, 47.5), (0.0, 52.5, 40.0, 47.5)], + ..CASE + }, + Case { + rule: "vertical wrap, columns packed in the centre: two columns of 20, 5 apart, 55 / 2 before", + vertical: true, + cross: Center, + kids: &[(20.0, 40.0), (20.0, 40.0), (20.0, 40.0)], + want: &[(27.5, 0.0, 20.0, 40.0), (27.5, 50.0, 20.0, 40.0), (52.5, 0.0, 20.0, 40.0)], + ..CASE + }, + Case { + rule: "vertical wrap, space between columns: 100 - 40 between", + vertical: true, + content: Between, + kids: &[(20.0, 40.0), (20.0, 40.0), (20.0, 40.0)], + want: &[(0.0, 0.0, 20.0, 40.0), (0.0, 50.0, 20.0, 40.0), (80.0, 0.0, 20.0, 40.0)], + ..CASE + }, + ]; + for c in &cases { + let got = run(c); + assert_eq!(got.len(), c.want.len(), "{}", c.rule); + for (i, (g, w)) in got.iter().zip(c.want).enumerate() { + let close = [(g.0, w.0), (g.1, w.1), (g.2, w.2), (g.3, w.3)].iter().all(|(a, b)| (a - b).abs() < 1e-6); + assert!(close, "{}: child {i} is {g:?}, want {w:?}", c.rule); + } + } +} + +#[test] +fn a_wrapping_frame_without_children_lays_out() { + assert!(run(&Case { kids: &[], content: Between, ..CASE }).is_empty()); +} + +#[test] +fn non_finite_and_tiny_frames_place_finitely() { + for (w, h, gap) in [(0.0, 0.0, 5.0), (100.0, 1e-9, f64::NAN), (1e12, 1e12, f64::INFINITY), (50.0, 100.0, -1e9)] { + let got = run(&Case { size: (w, h), cross_gap: gap, content: Between, cross: Center, ..CASE }); + assert!(got.iter().all(|r| r.0.is_finite() && r.1.is_finite()), "{w} × {h}, gap {gap}: {got:?}"); + } +} diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 94b639e..fd7ff00 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -15,8 +15,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] use figcraft_doc::{ - AutoLayout, CellAlign, CrossAlign, Document, GridAlign, GridKind, HConstraint, LayoutGrid, LayoutMode, MainAlign, NodeId, NodeKind, Sizing, - StrokeAlign, TextAutoResize, VConstraint, + AutoLayout, CellAlign, ContentAlign, CrossAlign, Document, GridAlign, GridKind, HConstraint, LayoutGrid, LayoutMode, MainAlign, NodeId, NodeKind, + Sizing, StrokeAlign, TextAutoResize, VConstraint, }; use figcraft_geom::{Affine, Point, Rect}; @@ -472,9 +472,35 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) { let inner_main = if horizontal { nw - pl - pr } else { nh - pt - pb }; let inner_cross = if horizontal { nh - pt - pb } else { nw - pl - pr }; - // Position. + // Position. Several lines share the free space across the flow: packed lines sit where the + // cross alignment says (lines of nothing but fill children stretch over it instead), or the + // space goes between them. let mut cross_pos = if horizontal { pt } else { pl }; let single = lines.len() == 1; + let mut cross_gap = cross_gap; + if lines.len() > 1 { + let lines_cross: f64 = line_extents.iter().map(|l| l.1).sum(); + let between = (lines.len() - 1) as f64; + let spread = (inner_cross - lines_cross) / between; + if al.content_align == ContentAlign::SpaceBetween && spread > 0.0 { + cross_gap = spread; + } else { + if al.content_align == ContentAlign::SpaceBetween { + cross_gap = 0.0; + } + let free = inner_cross - lines_cross - cross_gap * between; + if free > 0.0 && items.iter().all(|it| it.fill_cross) { + let extra = free / lines.len() as f64; + line_extents.iter_mut().for_each(|l| l.1 += extra); + } else { + cross_pos += match al.cross_align { + CrossAlign::Min | CrossAlign::Baseline => 0.0, + CrossAlign::Center => free / 2.0, + CrossAlign::Max => free, + }; + } + } + } for (li, line) in lines.iter().enumerate() { let (used, line_cross) = line_extents.get(li).copied().unwrap_or((0.0, 0.0)); let line_cross = if single { inner_cross } else { line_cross }; @@ -1073,6 +1099,8 @@ pub fn grid_snap_lines_in(doc: &Document, frame: NodeId, xf: Affine) -> (Vec Vec { tool( "set_properties", "Set properties", - "Set properties on one or more layers as one undo step (engine `node.set`). Keys: name, visible, locked, opacity (0-1), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), fill (colour) / fills (array of paints: \"#hex\" or {\"type\":\"solid\",\"color\":\"#hex\"} or {\"type\":\"linearGradient\",\"stops\":[\"#hex\",\"#hex\"]} or stops [{\"position\":0..1,\"color\":\"#hex\"}], optional from/to handles in the unit box; radialGradient too), stroke / strokes, strokeWeight, strokeAlign (inside|center|outside), cornerRadius (n or [tl,tr,br,bl]), effects (array, e.g. {\"type\":\"dropShadow\",\"color\":\"#00000040\",\"offset\":[0,4],\"radius\":8}), clip, layout (null|horizontal|vertical|grid), gap, padding (n or [t,r,b,l]), mainAlign (min|center|max|spaceBetween|spaceAround|spaceEvenly), crossAlign, wrap, sizingH / sizingV (fixed|hug|fill), constraints {horizontal, vertical}, characters, fontFamily, fontStyle, fontSize, lineHeight, letterSpacing, textAlign, textAutoResize. Run list_commands with filter \"node.set\" for the full list. Unknown keys are an error. Returns the updated layers.", + "Set properties on one or more layers as one undo step (engine `node.set`). Keys: name, visible, locked, opacity (0-1), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), fill (colour) / fills (array of paints: \"#hex\" or {\"type\":\"solid\",\"color\":\"#hex\"} or {\"type\":\"linearGradient\",\"stops\":[\"#hex\",\"#hex\"]} or stops [{\"position\":0..1,\"color\":\"#hex\"}], optional from/to handles in the unit box; radialGradient too), stroke / strokes, strokeWeight, strokeAlign (inside|center|outside), cornerRadius (n or [tl,tr,br,bl]), effects (array, e.g. {\"type\":\"dropShadow\",\"color\":\"#00000040\",\"offset\":[0,4],\"radius\":8}), clip, layout (null|horizontal|vertical|grid), gap, padding (n or [t,r,b,l]), mainAlign (min|center|max|spaceBetween|spaceAround|spaceEvenly), crossAlign, wrap, contentAlign (auto|spaceBetween: wrapped lines), sizingH / sizingV (fixed|hug|fill), constraints {horizontal, vertical}, characters, fontFamily, fontStyle, fontSize, lineHeight, letterSpacing, textAlign, textAutoResize. Run list_commands with filter \"node.set\" for the full list. Unknown keys are an error. Returns the updated layers.", obj( json!({"ids": ids_schema("Layer ids (default: the selection)"), "props": {"type": "object", "description": "Property values to set"}}), &["props"], diff --git a/crates/ui-egui/src/layout_panel.rs b/crates/ui-egui/src/layout_panel.rs index ec919a2..bb60ebe 100644 --- a/crates/ui-egui/src/layout_panel.rs +++ b/crates/ui-egui/src/layout_panel.rs @@ -288,7 +288,11 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { MainAlign::Max => 2, MainAlign::SpaceBetween | MainAlign::SpaceAround | MainAlign::SpaceEvenly => 9, }; + // Wrapped lines spread across the flow light the whole cross axis, like an auto gap does + // the main one. + let spread = !grid_mode && l.wrap && l.content_align == ContentAlign::SpaceBetween; let cur_cross = match l.cross_align { + _ if spread => 9, CrossAlign::Min | CrossAlign::Baseline => 0, CrossAlign::Center => 1, CrossAlign::Max => 2, @@ -299,7 +303,7 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { let r = Rect::from_center_size(c, vec2(22.0, 20.0)); let resp = ui.interact(r, Id::new(("al_grid", xi, yi)), Sense::click()); let (main_i, cross_i) = if horizontal { (xi, yi) } else { (yi, xi) }; - let on = (cur_main == main_i || cur_main == 9) && cur_cross == cross_i; + let on = (cur_main == main_i || cur_main == 9) && (cur_cross == cross_i || cur_cross == 9); if on { ui.painter().rect_filled( Rect::from_center_size(c, vec2(if horizontal { 4.0 } else { 12.0 }, if horizontal { 12.0 } else { 4.0 })), @@ -350,17 +354,8 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { } let auto = l.main_align.is_auto_gap(); let gap_icon = if l.mode == LayoutMode::Vertical { Icon::DistributeV } else { Icon::Gap }; - let e = crate::variable_ui::number_field( - app, - ui, - ids, - "gap", - Id::new("al_gap"), - Lead::Icon(gap_icon, tr("Gap between items")), - if auto { None } else { Some(l.gap) }, - "", - 96.0, - ); + let (shown, lead) = ((!auto).then_some(l.gap), Lead::Icon(gap_icon, tr("Gap between items"))); + let e = crate::variable_ui::number_field_or(app, ui, ids, "gap", Id::new("al_gap"), shown, 96.0, tr("Auto"), lead, ""); app.field(ids, "gap", e, |v| json!(v)); ui.add_space(8.0); let label = match l.main_align { @@ -430,12 +425,37 @@ fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { if l.mode != LayoutMode::Grid && l.wrap { // Wrapped rows (horizontal) or columns (vertical): the gap between them. let vertical = l.mode == LayoutMode::Vertical; - widgets::sublabels(ui, if vertical { tr("Column gap") } else { tr("Row gap") }, None); + let name = if vertical { tr("Column gap") } else { tr("Row gap") }; + widgets::sublabels(ui, name, None); row(ui, 32.0, |ui| { let (icon, tip) = if vertical { (Icon::Gap, tr("Gap between columns")) } else { (Icon::DistributeV, tr("Gap between rows")) }; - let e = - crate::variable_ui::number_field(app, ui, ids, "crossGap", Id::new("al_crossgap"), Lead::Icon(icon, tip), Some(l.cross_gap), "", col); - app.field(ids, "crossGap", e, |v| json!(v.max(0.0))); + let spread = l.content_align == ContentAlign::SpaceBetween; + let shown = (!spread).then_some(l.cross_gap); + let e = crate::variable_ui::number_field_or( + app, + ui, + ids, + "crossGap", + Id::new("al_crossgap"), + shown, + col, + tr("Auto"), + Lead::Icon(icon, tip), + "", + ); + // A typed gap is a fixed gap. + app.field_run(e, "node.set", |v| json!({"ids": ids, "props": {"crossGap": v.max(0.0), "contentAlign": "auto"}})); + let label = if spread { tr("Space between") } else { tr("Fixed gap") }; + let resp = widgets::dropdown(ui, label, col, None); + crate::a11y::combo(&resp, name, label, crate::a11y::menu_open(&resp)); + menu_popup(&resp).show(|ui| { + for (l, v) in [(tr("Fixed"), "auto"), (tr("Auto (space between)"), "spaceBetween")] { + if menu_item(ui, l, "") { + app.set_props(ids, json!({"contentAlign": v})); + ui.close(); + } + } + }); }); } if l.mode == LayoutMode::Grid { diff --git a/docs/auto-layout-parity.md b/docs/auto-layout-parity.md index f98e612..49be0e4 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-11 · **Change:** minor (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-13 · **Change:** minor (core editor leftovers: 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%, @@ -40,7 +40,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver | Baseline alignment of a layer without text (an icon next to text) | silent | it keeps to the top of the line | | Horizontal wrap: a new row when the width runs out; gap between rows | pass | `wrap_rules` | | Vertical wrap: a new column when the height runs out; not masonry; gap between columns | **added** | layout, Design panel wrap toggle, column gap field, on-canvas gap handles, drag reorder and arrow keys by column | -| Wrapped rows/columns align their contents (space between rows) | silent | the help centre doesn't describe it; not implemented (the file format has a setting for it) | +| Wrapped rows/columns as a block: packed at the start, centre or end the cross alignment names; stretched when every child fills across the flow; or the free space between them (one line or no free space: like packed) | **added** | `content_align_tests` (13 cases), `al_content_align_tests`; `contentAlign`, the Row/Column gap mode in the Design panel, REST and `.fig` both ways, `align-content` in CSS | | Ignore auto layout: out of the flow, kept in the frame; constraints apply only then | pass | `absolute_position_rules` | | Canvas stacking: last on top by default, first on top on request; the layer order stays | **fixed** + **added** | `reverseZ` was stored but never drawn: now the canvas, PNG, SVG and PDF exports and hit testing paint in that order; Design panel control | | Grid: `fr` tracks share by fraction; fixed tracks keep their size; hug tracks fit their content | pass | `grid_rules` | @@ -77,5 +77,6 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver | Date | Change | Summary | |---|---|---| +| 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-11 | minor | Behaviour suite from the public documentation (64 layout cases, 8 command tests). Fixed: padding floor, inside-only included strokes (and included by default), auto gaps stop at 0, grid `fr` like CSS `fr`, canvas stacking actually drawn and hit. Added: auto gaps evenly/around, vertical wrap, grid cell alignment, strokes and canvas stacking controls. Breadth 95% → 98%, ready 68% → 74%, remaining 6–12 h → 3–6 h | | 2026-10-10 | major | Created from the 2026-10-10 re-measure | diff --git a/docs/mcp.md b/docs/mcp.md index 675aaaa..de8ead0 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -97,6 +97,9 @@ 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). + - `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`). - Grid (`layout: "grid"`): `gridColumns`, `gridRows` (minimum; rows grow), `columnGap` / `rowGap`, track sizes `columnTracks` / `rowTracks` as `[120, "1fr", "auto"]` (px fixed, `fr` fill share, `auto` hug) or the `layout.setTrack {axis, index, sizing, value?}` command; on a diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e8925e7..bccdd32 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 (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: 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 @@ -102,7 +102,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 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 | -| 9 | Auto layout & constraints | 9 | 98% | 74% | Behaviour against real Figma files; wrapped-line content alignment (behaviour suite from the documentation, `fr` tracks like CSS, vertical wrap, auto gaps evenly/around and cell alignment landed) ([auto-layout-parity](auto-layout-parity.md)) | 3–6 | +| 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 | | 12 | Styles & variables | 5 | 95% | 76% | Variables inside styles, real-library QA (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 | @@ -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 | 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 | | 2026-10-12 | minor | Everyday editing partials closed (gap 4): number fields scrub a step a point (×10 with Shift, ×0.1 with Alt, on past the screen edge where the platform reports it), step with the arrow keys by the nudge preferences, take brackets, units and relative edits (`+10`, `*2`, `50%`), commit on Tab and nothing when left untouched, and on Mixed apply relative edits to each layer in one undo step; outline mode's Include hidden layers and Include object bounds (`view.outlinesHiddenLayers`, `view.outlinesObjectBounds`), drawn and clickable; Hide when publishing from the Assets panel (`component.hideWhenPublishing`); a cursor for every tool, modifier and hover target (rotate turned to its corner, pen states, eyedropper, comment, copy, not-allowed), with guides and handles keeping theirs; panel widths with limits, double-click reset and the preferences (`view.panelWidths`); a file or page made in the dark theme has a #1E1E1E page (sessions without an interface keep #F5F5F5) and text drawn on a dark page is white. Menu breadth 211/211. UI/UX fidelity 58% and the interaction pass 40–60 h unchanged at this rounding: the side-by-side comparison is what remains | | 2026-10-12 | minor | Text metrics, first measured fidelity data (gap 3): our example documents, exported and re-saved by other software, were read back and every text layer's stored box, line heights, baselines and glyph positions compared with ours. Three rules explained every row and were applied: a line's height is rounded to whole pixels (halves up; Auto, % and px alike; the baseline keeps its fraction inside the rounded line); glyph advances and kerning are each rounded to 1/1024 em before scaling by the font size; letter spacing is not added after a line's last visible glyph. Before: 24 of 131 text boxes of the design-system example matched (118 in width, 24 in height); after: 131 of 131, and all 230 self-sizing text boxes across the 7 documents (Inter only, 11–120 px, letter spacing −6% to 8%, one wrapped paragraph). Glyph positions within 0.001 px: 223 of 1,832 → 1,781; the rest are kerning pairs at weights where the installed font files differ slightly, at most 0.05 px. Frames and other layers matching in size and position: 115 of 231 → 208; the other 23 hang on one grid frame whose row positions came back as written. Not measured: other font families, fractional px line heights, centred or right-aligned text with letter spacing, lists, mixed sizes on a line. Wording only; no number moves |