diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1755,6 +1755,7 @@ version = "0.11.2"
 dependencies = [
  "figcraft-doc",
  "figcraft-geom",
+ "serde_json",
 ]
 
 [[package]]
diff --git a/ROADMAP.md b/ROADMAP.md
--- 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-26 · **Change:** minor (colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (layout extensions extracted for independent review; colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
 
 FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
 native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -132,6 +132,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
 
 ## Progress log
 
+- **2026-10-27** · M2: layout extensions from #1334, reviewed independently of overlays, portable fonts and text wrapping; default margins stay out of saved files. Numbers unchanged.
+
 - **2026-10-26** · Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` runs the name through `deunicode` before its ASCII filter, so 로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` in the HTML/CSS/React/Leptos class names, component roots and `data-*` variant attributes (SwiftUI/Compose type names unchanged); a name left with nothing (symbols only) takes the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. New dependency: deunicode (pure Rust tables, zero deps, BSD-3-Clause, the `slug` crate's base). `html_codegen_tests`. Numbers unchanged.
 - **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged.
 - **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change.
@@ -348,6 +350,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-27 | minor | M2: layout extensions from #1334, reviewed independently of overlays, portable fonts and text wrapping; default margins stay out of saved files. Numbers unchanged. |
 | 2026-10-26 | minor | Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` transliterates through the new `deunicode` dependency (pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`) in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes; symbols-only names take the node's kind (`.text`) instead of `layer-`, and the 40-character cut lands on a word boundary. `html_codegen_tests` updated and extended. Numbers unchanged |
 | 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. |
 | 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged |
diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs
--- a/crates/doc/src/props.rs
+++ b/crates/doc/src/props.rs
@@ -1013,6 +1013,9 @@ pub enum CrossAlign {
     Center,
     Max,
     Baseline,
+    /// The children stretch across the cross axis (CSS `align-items: stretch`): each takes the
+    /// line's cross size, unless it asks for another alignment of its own.
+    Stretch,
 }
 
 /// How the lines of a wrapping layout (rows of a horizontal one, columns of a vertical one) share
@@ -1026,6 +1029,10 @@ pub enum ContentAlign {
     Auto,
     /// The free space goes between the lines (one line, or no free space: like `Auto`).
     SpaceBetween,
+    /// The free space goes between the lines, with half as much at each edge.
+    SpaceAround,
+    /// The free space goes between the lines and at each edge, all alike.
+    SpaceEvenly,
 }
 
 /// Auto layout container properties (a frame with auto layout).
@@ -1055,6 +1062,8 @@ pub struct AutoLayout {
     pub strokes_included: bool,
     /// Canvas stacking: the first child paints on top (default: the last one does).
     pub reverse_z: bool,
+    /// Reverse the children within each line, preserving the frame's main-axis alignment.
+    pub reverse: bool,
 }
 
 impl Default for AutoLayout {
@@ -1075,6 +1084,7 @@ impl Default for AutoLayout {
             // New auto layout includes inside strokes.
             strokes_included: true,
             reverse_z: false,
+            reverse: false,
         }
     }
 }
@@ -1109,26 +1119,45 @@ pub struct GridTrack {
     pub sizing: Sizing,
     /// Fixed: the size in px. Fill: the fractional units. Hug: unused.
     pub value: f64,
+    /// The track takes this share of the frame's inner size (0–1) instead of a fixed size: CSS
+    /// `percent`. Off by default.
+    pub percent: bool,
 }
 
 impl Default for GridTrack {
     fn default() -> Self {
-        Self { sizing: Sizing::Fill, value: 1.0 }
+        Self { sizing: Sizing::Fill, value: 1.0, percent: false }
     }
 }
 
 impl GridTrack {
     pub fn fixed(px: f64) -> Self {
-        Self { sizing: Sizing::Fixed, value: px }
+        Self { sizing: Sizing::Fixed, value: px, percent: false }
     }
     pub fn hug() -> Self {
-        Self { sizing: Sizing::Hug, value: 0.0 }
+        Self { sizing: Sizing::Hug, value: 0.0, percent: false }
     }
     pub fn fill(fr: f64) -> Self {
-        Self { sizing: Sizing::Fill, value: fr }
+        Self { sizing: Sizing::Fill, value: fr, percent: false }
+    }
+    /// A track that takes this share of the frame's inner size (CSS `percent`).
+    pub fn percent(share: f64) -> Self {
+        Self { sizing: Sizing::Fixed, value: share, percent: true }
     }
 }
 
+/// Per-item alignment override in an auto layout row/column. `Stretch` expands the child
+/// along the cross axis (within its min/max size), independently of the parent alignment.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum ItemAlign {
+    Min,
+    Center,
+    Max,
+    Stretch,
+    Baseline,
+}
+
 /// Layout properties a node has as a child (of an auto layout frame) or as a resizable box.
 #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
 #[serde(default)]
@@ -1137,6 +1166,12 @@ pub struct ChildLayout {
     pub sizing_v: Sizing,
     /// Ignore auto layout (absolute position inside an auto layout frame).
     pub absolute: bool,
+    /// Outer spacing [top, right, bottom, left], may be negative (Penpot/CSS-like).
+    #[serde(skip_serializing_if = "zero_margin")]
+    pub margin: [f64; 4],
+    /// Override for this item's cross-axis alignment in a flex layout.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub align_self: Option<ItemAlign>,
     pub min_w: Option<f64>,
     pub max_w: Option<f64>,
     pub min_h: Option<f64>,
@@ -1154,6 +1189,10 @@ pub struct ChildLayout {
     pub grid_align_v: Option<CellAlign>,
 }
 
+fn zero_margin(v: &[f64; 4]) -> bool {
+    *v == [0.0; 4]
+}
+
 impl ChildLayout {
     /// Column span, at least 1.
     pub fn column_span(&self) -> u32 {
diff --git a/crates/engine/src/al_flexbox_tests.rs b/crates/engine/src/al_flexbox_tests.rs
new file mode 100644
--- /dev/null
+++ b/crates/engine/src/al_flexbox_tests.rs
@@ -0,0 +1,126 @@
+//! The flexbox rules Penpot files use and Figma has no name for: a reversed flow, a stretched
+//! cross axis and a percentage grid track. Each test drives them through commands (the props, the
+//! undo, the CSS and inspect output, the REST and `.fig` mappings) and checks the places the
+//! layout gives the children.
+
+use serde_json::{Value, json};
+
+use crate::params::id_of;
+use crate::{EngineError, Session};
+use figcraft_doc::{CrossAlign, NodeId};
+
+fn id(v: Value) -> NodeId {
+    id_of(&v["id"]).unwrap()
+}
+
+/// A 100 × 100 frame of two 40 × 20 rectangles.
+fn row(s: &mut Session, props: Value) -> (NodeId, Vec<NodeId>) {
+    let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100, "props": props})).unwrap());
+    let kids = (0..2).map(|_| id(s.execute("shape.rectangle", &json!({"parent": f, "x": 0, "y": 0, "width": 40, "height": 20})).unwrap())).collect();
+    (f, kids)
+}
+
+/// Each child's `(x, y, w, h)` in the frame.
+fn rects(s: &Session, kids: &[NodeId]) -> Vec<(f64, f64, f64, f64)> {
+    kids.iter()
+        .map(|k| {
+            let n = s.doc().unwrap().node(*k).unwrap();
+            let t = n.transform.as_coeffs();
+            (t[4], t[5], n.width, n.height)
+        })
+        .collect()
+}
+
+#[test]
+fn a_reversed_flow_lays_the_children_out_in_the_opposite_order() {
+    let mut s = Session::new();
+    let (f, kids) = row(&mut s, json!({"layout": "horizontal"}));
+    assert_eq!(rects(&s, &kids), vec![(0.0, 0.0, 40.0, 20.0), (40.0, 0.0, 40.0, 20.0)]);
+    s.execute("node.set", &json!({"id": f, "props": {"reverse": true}})).unwrap();
+    assert_eq!(rects(&s, &kids), vec![(40.0, 0.0, 40.0, 20.0), (0.0, 0.0, 40.0, 20.0)]);
+    s.execute("edit.undo", &json!({})).unwrap();
+    assert_eq!(rects(&s, &kids), vec![(0.0, 0.0, 40.0, 20.0), (40.0, 0.0, 40.0, 20.0)]);
+}
+
+#[test]
+fn the_reverse_prop_has_its_alias_and_a_junk_value_is_refused() {
+    let mut s = Session::new();
+    let (f, _) = row(&mut s, json!({"layout": "horizontal"}));
+    s.execute("node.set", &json!({"id": f, "props": {"reverseDirection": true}})).unwrap();
+    assert!(s.doc().unwrap().node(f).unwrap().layout.as_ref().unwrap().reverse);
+    // A value that is not a boolean is refused, and the setting stays as it was.
+    let r = s.execute("node.set", &json!({"id": f, "props": {"reverse": "sideways"}}));
+    assert!(matches!(r, Err(EngineError::BadParams { .. })), "{r:?}");
+    assert!(s.doc().unwrap().node(f).unwrap().layout.as_ref().unwrap().reverse);
+}
+
+#[test]
+fn a_stretched_cross_axis_gives_the_children_the_line_size() {
+    let mut s = Session::new();
+    let (f, kids) = row(&mut s, json!({"layout": "horizontal"}));
+    s.execute("node.set", &json!({"id": f, "props": {"crossAlign": "stretch"}})).unwrap();
+    assert_eq!(s.doc().unwrap().node(f).unwrap().layout.as_ref().unwrap().cross_align, CrossAlign::Stretch);
+    // The frame is 100 tall: the children take its whole height.
+    assert_eq!(rects(&s, &kids), vec![(0.0, 0.0, 40.0, 100.0), (40.0, 0.0, 40.0, 100.0)]);
+}
+
+#[test]
+fn css_and_inspect_name_the_new_settings() {
+    let mut s = Session::new();
+    let (f, _) = row(&mut s, json!({"layout": "horizontal"}));
+    let css = |s: &mut Session| s.execute("inspect.code", &json!({"id": f})).unwrap().to_string();
+    s.execute("node.set", &json!({"id": f, "props": {"reverse": true}})).unwrap();
+    assert!(css(&mut s).contains("flex-direction: row-reverse;"), "{}", css(&mut s));
+    assert!(css(&mut s).contains("justify-content: flex-end;"), "{}", css(&mut s));
+    s.execute("node.set", &json!({"id": f, "props": {"mainAlign": "max"}})).unwrap();
+    assert!(css(&mut s).contains("justify-content: flex-start;"), "{}", css(&mut s));
+    s.execute("node.set", &json!({"id": f, "props": {"crossAlign": "stretch"}})).unwrap();
+    assert!(css(&mut s).contains("align-items: stretch;"), "{}", css(&mut s));
+    let rows = s.execute("inspect.properties", &json!({"id": f})).unwrap().to_string();
+    assert!(rows.contains("stretch"), "{rows}");
+}
+
+#[test]
+fn default_child_margins_do_not_change_saved_nodes() {
+    let mut child = figcraft_doc::ChildLayout::default();
+    assert!(serde_json::to_value(&child).unwrap().get("margin").is_none());
+    child.margin[0] = -5.0;
+    let json = serde_json::to_value(&child).unwrap();
+    assert_eq!(json["margin"], json!([-5.0, 0.0, 0.0, 0.0]));
+    assert_eq!(serde_json::from_value::<figcraft_doc::ChildLayout>(json).unwrap(), child);
+}
+
+#[test]
+fn rest_and_fig_carry_the_reverse_setting() {
+    let frame = |extra: Value| {
+        let mut f = json!({"id": "1:2", "name": "Row", "type": "FRAME", "relativeTransform": [[1, 0, 0], [0, 1, 0]], "size": {"x": 100, "y": 100},
+            "layoutMode": "HORIZONTAL", "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| figcraft_format::rest::import(file, &figcraft_format::rest::no_images).unwrap().0;
+    let of = |doc: &figcraft_doc::Document| doc.nodes().find(|n| n.name == "Row").unwrap().layout.as_ref().unwrap().reverse;
+    assert!(!of(&read(&frame(json!({})))));
+    assert!(of(&read(&frame(json!({"layoutReverse": true})))));
+    // Written to `.fig` and read back, the setting survives.
+    let doc = read(&frame(json!({"layoutReverse": true})));
+    let (bytes, _) = figcraft_format::fig::export(&doc, None).unwrap();
+    let (back, _) = figcraft_format::fig::import(&bytes, "x").unwrap();
+    assert!(of(&back));
+}
+
+#[test]
+fn grid_commands_preserve_percentage_tracks_and_reject_nonfinite_shares() {
+    let mut s = Session::new();
+    let (f, _) = row(&mut s, json!({"layout": "grid"}));
+    s.execute("node.set", &json!({"id": f, "props": {"columnTracks": ["50%", "auto"], "rowTracks": ["25%", "1fr"]}})).unwrap();
+    let layout = s.doc().unwrap().node(f).unwrap().layout.as_ref().unwrap();
+    assert_eq!(layout.column_tracks[0], figcraft_doc::GridTrack::percent(0.5));
+    assert_eq!(layout.row_tracks[0], figcraft_doc::GridTrack::percent(0.25));
+    for value in ["NaN%", "inf%", "1e999%", "%"] {
+        assert!(s.execute("node.set", &json!({"id": f, "props": {"columnTracks": [value]}})).is_err(), "{value}");
+        assert_eq!(s.doc().unwrap().node(f).unwrap().layout.as_ref().unwrap().column_tracks[0], figcraft_doc::GridTrack::percent(0.5));
+    }
+    s.execute("edit.undo", &json!({})).unwrap();
+    assert!(s.doc().unwrap().node(f).unwrap().layout.as_ref().unwrap().column_tracks.is_empty());
+}
diff --git a/crates/engine/src/assets/components/diff.rs b/crates/engine/src/assets/components/diff.rs
--- a/crates/engine/src/assets/components/diff.rs
+++ b/crates/engine/src/assets/components/diff.rs
@@ -83,7 +83,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 || x.content_align != y.content_align {
+        if x.main_align != y.main_align || x.cross_align != y.cross_align || x.content_align != y.content_align || x.reverse != y.reverse {
             out.push("alignment");
         }
     }
diff --git a/crates/engine/src/assets/components/guard.rs b/crates/engine/src/assets/components/guard.rs
--- a/crates/engine/src/assets/components/guard.rs
+++ b/crates/engine/src/assets/components/guard.rs
@@ -104,6 +104,7 @@ pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Opti
         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.reverse = false;
             l
         })
     };
diff --git a/crates/engine/src/assets/components/instances.rs b/crates/engine/src/assets/components/instances.rs
--- a/crates/engine/src/assets/components/instances.rs
+++ b/crates/engine/src/assets/components/instances.rs
@@ -164,7 +164,10 @@ 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, b.content_align) = (a.main_align, a.cross_align, a.content_align),
+                    _ => {
+                        (b.main_align, b.cross_align, b.content_align) = (a.main_align, a.cross_align, a.content_align);
+                        b.reverse = a.reverse;
+                    }
                 }
             }
         }
diff --git a/crates/engine/src/canvas/distance.rs b/crates/engine/src/canvas/distance.rs
--- a/crates/engine/src/canvas/distance.rs
+++ b/crates/engine/src/canvas/distance.rs
@@ -289,7 +289,7 @@ pub fn padding(doc: &Document, frame: NodeId) -> Vec<Guide> {
         .collect();
     let content = kids.iter().copied().reduce(|a, b| a.union(b));
     let (cx, cy) = content.map_or((n.width / 2.0, n.height / 2.0), |r| (r.center().x, r.center().y));
-    let [top, right, bottom, left] = l.padding.map(|v| if v.is_finite() { v.max(0.0) } else { 0.0 });
+    let [top, right, bottom, left] = l.padding.map(|v| if v.is_finite() { v.clamp(-100_000.0, 100_000.0) } else { 0.0 });
     let mut push = |a: Point, b: Point| measured(&mut out, w * a, w * b);
     push(Point::new(0.0, cy), Point::new(left, cy));
     push(Point::new(n.width - right, cy), Point::new(n.width, cy));
diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs
--- a/crates/engine/src/cmd/layout.rs
+++ b/crates/engine/src/cmd/layout.rs
@@ -175,9 +175,9 @@ mod tests {
         assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, [8.0, 4.0, 16.0, 4.0]);
         ex(&mut s, "node.set", json!({"id": f, "props": {"paddingVertical": 2}}));
         assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, [2.0, 4.0, 2.0, 4.0]);
-        // Negatives clamp to 0; junk is refused.
+        // Padding is signed (Penpot files use negative padding); junk is refused.
         ex(&mut s, "node.set", json!({"id": f, "props": {"paddingTop": -5}}));
-        assert_eq!(node(&s, f).layout.as_ref().unwrap().padding[0], 0.0);
+        assert_eq!(node(&s, f).layout.as_ref().unwrap().padding[0], -5.0);
         assert!(s.execute("node.set", &json!({"id": f, "props": {"paddingLeft": "wide"}})).is_err());
     }
 
diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs
--- a/crates/engine/src/cmd/props.rs
+++ b/crates/engine/src/cmd/props.rs
@@ -15,11 +15,11 @@ pub const STROKE_SIDES: [&str; 4] = ["strokeTopWeight", "strokeRightWeight", "st
 pub const KEYS: &str = "name, visible, locked, opacity (0-1), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), \
 fills (paints or colours; gradients as {type: linearGradient|radialGradient|angularGradient|diamondGradient, stops: [\"#hex\", ...], from?, to?} too), fill (colour, or \"none\"/null: no fill), strokes, stroke (colour, or \"none\"/null: no stroke), 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), 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}]), \
+clip, isMask, layout (null|horizontal|vertical|grid|{...}), gap, crossGap, padding (signed 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|stretch|baseline: first text baselines, horizontal layouts), contentAlign (wrapped lines: auto = packed where crossAlign says | spaceBetween | spaceAround | spaceEvenly), wrap (rows of a horizontal layout, columns of a vertical one), reverse (reverse each line's child order, preserving main-axis alignment), 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 | \"50%\" | \"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, \
+absolute (ignore auto layout; follows constraints), itemMargin (signed n or [t,r,b,l]), marginTop/Right/Bottom/Left, alignSelf (auto|min|center|max|stretch|baseline), minWidth, maxWidth, minHeight, maxHeight (null removes), constraints {horizontal, vertical}, grids, exports, characters, fontFamily, fontStyle, \
 fontSize, lineHeight (auto|px|\"150%\"), letterSpacing (px|\"2%\"), textAlign (left|center|right|justified), textAlignVertical, textAutoResize \
 (widthAndHeight|height|none|truncate), textDecoration, textDecorationStyle (solid|dotted|wavy), textDecorationThickness / textDecorationOffset \
 (auto|px|\"10%\" of the font size; the offset runs from the baseline down to an underline's top edge), textDecorationSkipInk (underlines break around descenders), \
@@ -257,10 +257,10 @@ pub fn width_points_json(profile: Option<&WidthProfile>) -> Value {
     }
 }
 
-const TRACK_HELP: &str = "tracks: [px | \"120px\" | \"1fr\" | \"auto\" (hug) | {sizing: fixed|hug|fill, value}]";
+const TRACK_HELP: &str = "tracks: [px | \"120px\" | \"50%\" | \"1fr\" | \"auto\" (hug) | {sizing: fixed|hug|fill, value}]";
 
 /// One grid track from `120` / `"120px"` (fixed), `"2fr"` / `"fill"` (fill), `"auto"` / `"hug"`,
-/// or `{sizing, value}`.
+/// `"50%"` (percentage), or `{sizing, value}`.
 pub fn track_of(v: &Value) -> Option<GridTrack> {
     match v {
         Value::Number(_) => f64_of(v).map(|px| GridTrack::fixed(px.clamp(0.0, 1e6))),
@@ -269,9 +269,14 @@ pub fn track_of(v: &Value) -> Option<GridTrack> {
             match s.as_str() {
                 "auto" | "hug" => Some(GridTrack::hug()),
                 "fill" => Some(GridTrack::fill(1.0)),
-                _ => match s.strip_suffix("fr") {
-                    Some(fr) => fr.trim().parse::<f64>().ok().filter(|f| f.is_finite()).map(|f| GridTrack::fill(f.clamp(0.0, 1e4))),
-                    None => f64_of(&Value::String(s)).map(|px| GridTrack::fixed(px.clamp(0.0, 1e6))),
+                _ => match s.strip_suffix('%') {
+                    Some(percent) => {
+                        percent.trim().parse::<f64>().ok().filter(|f| f.is_finite()).map(|f| GridTrack::percent((f / 100.0).clamp(0.0, 1.0)))
+                    }
+                    None => match s.strip_suffix("fr") {
+                        Some(fr) => fr.trim().parse::<f64>().ok().filter(|f| f.is_finite()).map(|f| GridTrack::fill(f.clamp(0.0, 1e4))),
+                        None => f64_of(&Value::String(s)).map(|px| GridTrack::fixed(px.clamp(0.0, 1e6))),
+                    },
                 },
             }
         }
@@ -609,25 +614,47 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
             "crossGap" | "counterAxisSpacing" | "rowGap" => {
                 layout_mut(cmd, k, doc.get_mut(id)?)?.cross_gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?
             }
+            "itemMargin" | "margin" => {
+                let p = four(v).ok_or_else(|| bad(cmd, "itemMargin: n or [t,r,b,l]"))?;
+                doc.get_mut(id)?.child_layout.margin = p.map(|x| x.clamp(-100_000.0, 100_000.0));
+            }
+            "marginTop" | "marginRight" | "marginBottom" | "marginLeft" => {
+                let i = ["marginTop", "marginRight", "marginBottom", "marginLeft"].iter().position(|x| *x == k).unwrap_or(0);
+                let value = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.clamp(-100_000.0, 100_000.0);
+                doc.get_mut(id)?.child_layout.margin[i] = value;
+            }
+            "alignSelf" => {
+                let a = match v.as_str() {
+                    Some("min" | "start" | "flex-start") => Some(ItemAlign::Min),
+                    Some("center") => Some(ItemAlign::Center),
+                    Some("max" | "end" | "flex-end") => Some(ItemAlign::Max),
+                    Some("stretch") => Some(ItemAlign::Stretch),
+                    Some("baseline") => Some(ItemAlign::Baseline),
+                    Some("auto") => None,
+                    None if v.is_null() => None,
+                    _ => return Err(bad(cmd, "alignSelf: auto|min|center|max|stretch|baseline")),
+                };
+                doc.get_mut(id)?.child_layout.align_self = a;
+            }
             "padding" => {
                 let p = four(v).ok_or_else(|| bad(cmd, "padding: n or [t,r,b,l]"))?;
-                layout_mut(cmd, k, doc.get_mut(id)?)?.padding = p.map(|x| x.max(0.0));
+                layout_mut(cmd, k, doc.get_mut(id)?)?.padding = p.map(|x| x.clamp(-100_000.0, 100_000.0));
             }
             "paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft" => {
                 let i = ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"].iter().position(|x| *x == k).unwrap_or(0);
-                let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0);
+                let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.clamp(-100_000.0, 100_000.0);
                 if let Some(slot) = layout_mut(cmd, k, doc.get_mut(id)?)?.padding.get_mut(i) {
                     *slot = val;
                 }
             }
             "paddingHorizontal" => {
-                let val = f64_of(v).unwrap_or(0.0).max(0.0);
+                let val = f64_of(v).unwrap_or(0.0).clamp(-100_000.0, 100_000.0);
                 let l = layout_mut(cmd, k, doc.get_mut(id)?)?;
                 l.padding[1] = val;
                 l.padding[3] = val;
             }
             "paddingVertical" => {
-                let val = f64_of(v).unwrap_or(0.0).max(0.0);
+                let val = f64_of(v).unwrap_or(0.0).clamp(-100_000.0, 100_000.0);
                 let l = layout_mut(cmd, k, doc.get_mut(id)?)?;
                 l.padding[0] = val;
                 l.padding[2] = val;
@@ -637,12 +664,18 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
                     enum_of(v).ok_or_else(|| bad(cmd, "mainAlign: min|center|max|spaceBetween|spaceAround|spaceEvenly"))?
             }
             "crossAlign" | "counterAxisAlignItems" => {
-                layout_mut(cmd, k, doc.get_mut(id)?)?.cross_align = enum_of(v).ok_or_else(|| bad(cmd, "crossAlign: min|center|max|baseline"))?
+                layout_mut(cmd, k, doc.get_mut(id)?)?.cross_align =
+                    enum_of(v).ok_or_else(|| bad(cmd, "crossAlign/counterAxisAlignItems: min|center|max|stretch|baseline"))?
             }
             "contentAlign" | "counterAxisAlignContent" => {
-                layout_mut(cmd, k, doc.get_mut(id)?)?.content_align = enum_of(v).ok_or_else(|| bad(cmd, "contentAlign: auto|spaceBetween"))?
+                layout_mut(cmd, k, doc.get_mut(id)?)?.content_align =
+                    enum_of(v).ok_or_else(|| bad(cmd, "contentAlign: auto|spaceBetween|spaceAround|spaceEvenly"))?
             }
             "wrap" => layout_mut(cmd, k, doc.get_mut(id)?)?.wrap = v.as_bool().unwrap_or(false),
+            "reverse" | "reverseDirection" => {
+                let on = bool_of(v).ok_or_else(|| bad(cmd, "`reverse` must be a boolean"))?;
+                layout_mut(cmd, k, doc.get_mut(id)?)?.reverse = on;
+            }
             "gridColumns" | "gridRows" => {
                 let count = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.clamp(1.0, 1000.0) as u32;
                 let l = layout_mut(cmd, k, doc.get_mut(id)?)?;
@@ -1001,6 +1034,7 @@ pub fn override_group(k: &str) -> &'static str {
         "gap" | "itemSpacing" | "columnGap" | "crossGap" | "counterAxisSpacing" | "rowGap" => "gap",
         "padding" | "paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft" | "paddingHorizontal" | "paddingVertical" => "padding",
         "mainAlign" | "primaryAxisAlignItems" | "crossAlign" | "counterAxisAlignItems" | "contentAlign" | "counterAxisAlignContent" => "alignment",
+        "reverse" | "reverseDirection" => "alignment",
         "grids" | "layoutGrids" | "gridStyle" => "grids",
         "exports" | "exportSettings" => "exports",
         "interactions" => "interactions",
diff --git a/crates/engine/src/dev/codegen/arkui.rs b/crates/engine/src/dev/codegen/arkui.rs
--- a/crates/engine/src/dev/codegen/arkui.rs
+++ b/crates/engine/src/dev/codegen/arkui.rs
@@ -176,7 +176,8 @@ fn arkui_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[Str
                     CrossAlign::Min => None,
                     CrossAlign::Center => Some("Center"),
                     CrossAlign::Max => Some(if l.mode == LayoutMode::Vertical { "End" } else { "Bottom" }),
-                    CrossAlign::Baseline => None,
+                    // Children already carry their resolved dimensions.
+                    CrossAlign::Baseline | CrossAlign::Stretch => None,
                 };
                 if let Some(c) = cross {
                     let ty = if l.mode == LayoutMode::Vertical { "HorizontalAlign" } else { "VerticalAlign" };
diff --git a/crates/engine/src/dev/codegen/css.rs b/crates/engine/src/dev/codegen/css.rs
--- a/crates/engine/src/dev/codegen/css.rs
+++ b/crates/engine/src/dev/codegen/css.rs
@@ -86,7 +86,13 @@ pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bo
     }
     if let Some(l) = &n.layout {
         put("display", "flex".into());
-        put("flex-direction", if l.mode == LayoutMode::Vertical { "column" } else { "row" }.into());
+        let direction = match (l.mode, l.reverse) {
+            (LayoutMode::Vertical, true) => "column-reverse",
+            (LayoutMode::Vertical, false) => "column",
+            (_, true) => "row-reverse",
+            (_, false) => "row",
+        };
+        put("flex-direction", direction.into());
         if l.wrap {
             put("flex-wrap", "wrap".into());
         }
@@ -100,8 +106,10 @@ pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bo
         ];
         put("padding", sides.join(" "));
         let justify = match l.main_align {
+            MainAlign::Min if l.reverse => "flex-end",
             MainAlign::Min => "flex-start",
             MainAlign::Center => "center",
+            MainAlign::Max if l.reverse => "flex-start",
             MainAlign::Max => "flex-end",
             MainAlign::SpaceBetween => "space-between",
             MainAlign::SpaceAround => "space-around",
@@ -113,15 +121,21 @@ pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bo
             CrossAlign::Center => "center",
             CrossAlign::Max => "flex-end",
             CrossAlign::Baseline => "baseline",
+            CrossAlign::Stretch => "stretch",
         };
         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;
+            let spread = match l.content_align {
+                ContentAlign::SpaceBetween => "space-between",
+                ContentAlign::SpaceAround => "space-around",
+                ContentAlign::SpaceEvenly => "space-evenly",
+                ContentAlign::Auto => "flex-start",
+            };
             put(
                 "align-content",
-                if spread {
-                    "space-between"
+                if spread != "flex-start" {
+                    spread
                 } else if align == "baseline" {
                     "flex-start"
                 } else {
diff --git a/crates/engine/src/dev/devinspect.rs b/crates/engine/src/dev/devinspect.rs
--- a/crates/engine/src/dev/devinspect.rs
+++ b/crates/engine/src/dev/devinspect.rs
@@ -552,12 +552,16 @@ fn layout(doc: &Document, id: NodeId, n: &Node) -> Section {
 }
 
 fn auto_layout(doc: &Document, n: &Node, l: &AutoLayout) -> Section {
-    let dir = match (l.mode, l.wrap) {
-        (LayoutMode::Horizontal, true) => "Horizontal, wrap",
-        (LayoutMode::Horizontal, false) => "Horizontal",
-        (LayoutMode::Vertical, true) => "Vertical, wrap",
-        (LayoutMode::Vertical, false) => "Vertical",
-        (LayoutMode::Grid, _) => "Grid",
+    let dir = match (l.mode, l.wrap, l.reverse) {
+        (LayoutMode::Horizontal, true, false) => "Horizontal, wrap",
+        (LayoutMode::Horizontal, false, false) => "Horizontal",
+        (LayoutMode::Horizontal, true, true) => "Horizontal, wrap, reversed",
+        (LayoutMode::Horizontal, false, true) => "Horizontal, reversed",
+        (LayoutMode::Vertical, true, false) => "Vertical, wrap",
+        (LayoutMode::Vertical, false, false) => "Vertical",
+        (LayoutMode::Vertical, true, true) => "Vertical, wrap, reversed",
+        (LayoutMode::Vertical, false, true) => "Vertical, reversed",
+        (LayoutMode::Grid, _, _) => "Grid",
     };
     let mut rows = vec![prop("Direction", dir)];
     if l.mode == LayoutMode::Grid {
@@ -573,8 +577,13 @@ 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" };
-            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 spread = match l.content_align {
+                ContentAlign::SpaceBetween => Some("Auto (space between)"),
+                ContentAlign::SpaceAround => Some("Auto (space around)"),
+                ContentAlign::SpaceEvenly => Some("Auto (space evenly)"),
+                ContentAlign::Auto => None,
+            };
+            rows.push(if let Some(text) = spread { prop(label, text) } else { prop(label, num(l.cross_gap)).token(variable(doc, n, "crossGap")) });
         }
     }
     let [t, r, b, lft] = l.padding;
@@ -600,6 +609,7 @@ fn auto_layout(doc: &Document, n: &Node, l: &AutoLayout) -> Section {
         CrossAlign::Center => "center",
         CrossAlign::Max => "end",
         CrossAlign::Baseline => "baseline",
+        CrossAlign::Stretch => "stretch",
     };
     rows.push(prop("Align", format!("{main}, {cross}")));
     Section { title: "Auto layout", rows }
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -160,6 +160,8 @@ mod al_content_align_tests;
 #[cfg(test)]
 mod al_drag_tests;
 #[cfg(test)]
+mod al_flexbox_tests;
+#[cfg(test)]
 mod al_keys_tests;
 #[cfg(test)]
 mod al_nesting_tests;
diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs
--- a/crates/format/src/fig/export.rs
+++ b/crates/format/src/fig/export.rs
@@ -270,6 +270,21 @@ fn archive(
     Ok(buf.into_inner())
 }
 
+/// The plugin id and key under which a frame keeps the auto layout settings the format has no
+/// name for: a reversed flow, a stretched cross axis, the wrapped lines' space-around/space-evenly
+/// and the grid tracks' sizes.
+pub(super) const LAYOUT_STASH: (&str, &str) = ("figcraft", "layout");
+/// Longest kept description of such a layer (bytes of JSON).
+const MAX_STASH: usize = 1 << 20;
+
+/// Add a plugin data entry to a layer's fields, keeping the entries it already has.
+fn push_plugin_data(f: &mut Fields, plugin: &str, key: &str, value: &str) {
+    let mut list: Vec<Kv<'static>> = f.iter().find(|(k, _)| *k == "pluginData").map(|(_, v)| v.list().to_vec()).unwrap_or_default();
+    list.push(o(vec![("pluginID", s(plugin)), ("value", s(value)), ("key", s(key))]));
+    f.retain(|(k, _)| *k != "pluginData");
+    f.push(("pluginData", arr(list)));
+}
+
 type Fields = Vec<(&'static str, Kv<'static>)>;
 
 fn o(f: Fields) -> Kv<'static> {
@@ -300,8 +315,13 @@ fn b(x: bool) -> Kv<'static> {
 
 /// A track's size as the format's enum name and value, when both sizing functions agree (the
 /// importer keeps only those). `None`: the format has no name for it, so an equal track stands in.
-fn grid_track_size(track: &GridTrack) -> Option<(&'static str, f64)> {
-    let value = track.value;
+fn grid_track_size(track: &GridTrack, available: Option<f64>) -> Option<(&'static str, f64)> {
+    let value = if track.percent {
+        let Some(available) = available else { return Some(("HUG", 0.0)) };
+        available.max(0.0) * track.value.clamp(0.0, 1.0)
+    } else {
+        track.value
+    };
     let fits = value.is_finite() && (0.0..=crate::fig::map::MAX_GRID_TRACK_VALUE).contains(&value);
     match track.sizing {
         Sizing::Fixed if fits => Some(("FIXED", value)),
@@ -314,10 +334,10 @@ fn grid_track_size(track: &GridTrack) -> Option<(&'static str, f64)> {
 /// A grid axis's track sizes, keyed by track id like the order map. A track the document doesn't
 /// set is left out, and one the format can't hold keeps its id (the import equalises it) and sets
 /// `dropped`.
-fn grid_sizing(tracks: &[GridTrack], ids: &[u32], dropped: &mut bool) -> Option<Kv<'static>> {
+fn grid_sizing(tracks: &[GridTrack], ids: &[u32], available: Option<f64>, dropped: &mut bool) -> Option<Kv<'static>> {
     let mut entries = Vec::new();
     for (track, id) in tracks.iter().zip(ids) {
-        match grid_track_size(track) {
+        match grid_track_size(track, available) {
             Some((kind, value)) => {
                 let size = || o(vec![("type", e(kind)), ("value", fl(value))]);
                 entries.push(o(vec![("id", guid(AUX, *id)), ("trackSize", o(vec![("minSizing", size()), ("maxSizing", size())]))]));
diff --git a/crates/format/src/fig/export/common.rs b/crates/format/src/fig/export/common.rs
--- a/crates/format/src/fig/export/common.rs
+++ b/crates/format/src/fig/export/common.rs
@@ -1,8 +1,5 @@
 use super::*;
 
-/// Longest kept description of such a layer (bytes of JSON).
-const MAX_STASH: usize = 1 << 20;
-
 /// Whether a width profile reaches further on one side of the path than on the other.
 pub(super) fn one_sided(p: &figcraft_doc::WidthProfile) -> bool {
     p.points.iter().any(|q| q.bias.abs() > 1e-6)
diff --git a/crates/format/src/fig/export/layer.rs b/crates/format/src/fig/export/layer.rs
--- a/crates/format/src/fig/export/layer.rs
+++ b/crates/format/src/fig/export/layer.rs
@@ -49,7 +49,7 @@ impl<'d> Exporter<'d> {
             // (which the import puts back).
             self.warn("strokes wider on one side of their path were written as filled outlines (the stroke itself is kept for FigCraft)");
             let children = self.layer(&outline, parent, f, nested);
-            f.push(("pluginData", arr(vec![o(vec![("pluginID", s(STASH.0)), ("value", s(&original)), ("key", s(STASH.1))])])));
+            push_plugin_data(f, STASH.0, STASH.1, &original);
             return children;
         }
         let main = match &n.kind {
@@ -337,6 +337,8 @@ impl<'d> Exporter<'d> {
             CrossAlign::Center => "CENTER",
             CrossAlign::Max => "MAX",
             CrossAlign::Baseline => "BASELINE",
+            // Figma has no stretched cross axis; the children keep their own size.
+            CrossAlign::Stretch => "MIN",
         };
         // The main axis is the width for horizontal and grid layouts.
         let (main_hug, cross_hug) = {
@@ -368,6 +370,64 @@ impl<'d> Exporter<'d> {
                 ("stackCounterAlignContent", e(if spread { "SPACE_BETWEEN" } else { "AUTO" })),
             ]);
         }
+        // The settings the format has no name for are kept as plugin data (our extension of the
+        // file), so they survive a round trip through our own importer.
+        self.stash_layout(n, l, f);
+    }
+
+    /// Keep the auto layout settings the format has no name for (a reversed flow, a stretched
+    /// cross axis, the wrapped lines' space-around/space-evenly, percentage grid tracks) as
+    /// plugin data: the format's own fields can't carry them, and the import puts them back.
+    /// The names are the ones the file JSON uses, so the importer reads them as it reads the
+    /// rest.
+    fn stash_layout(&mut self, n: &Node, l: &AutoLayout, f: &mut Fields) {
+        let mut kept = serde_json::Map::new();
+        if l.reverse {
+            kept.insert("layoutReverse".into(), serde_json::json!(true));
+        }
+        if l.cross_align == CrossAlign::Stretch {
+            kept.insert("counterAxisAlignItems".into(), serde_json::json!("STRETCH"));
+        }
+        match l.content_align {
+            ContentAlign::SpaceAround => {
+                kept.insert("counterAxisAlignContent".into(), serde_json::json!("SPACE_AROUND"));
+            }
+            ContentAlign::SpaceEvenly => {
+                kept.insert("counterAxisAlignContent".into(), serde_json::json!("SPACE_EVENLY"));
+            }
+            _ => {}
+        }
+        let tracks = |specs: &[figcraft_doc::GridTrack]| {
+            specs
+                .iter()
+                .map(|t| {
+                    if t.percent {
+                        serde_json::json!({"percent": (t.value * 100.0).clamp(0.0, 100.0)})
+                    } else {
+                        match t.sizing {
+                            Sizing::Fixed => serde_json::json!({"fixed": t.value}),
+                            Sizing::Hug => serde_json::json!({"hug": true}),
+                            Sizing::Fill => serde_json::json!({"fill": t.value}),
+                        }
+                    }
+                })
+                .collect::<Vec<_>>()
+        };
+        if l.column_tracks.iter().any(|t| t.percent) {
+            kept.insert("columnTracks".into(), serde_json::json!(tracks(&l.column_tracks)));
+        }
+        if l.row_tracks.iter().any(|t| t.percent) {
+            kept.insert("rowTracks".into(), serde_json::json!(tracks(&l.row_tracks)));
+        }
+        // An instance with defaults must clear any extension inherited from its main.
+        if kept.is_empty() && n.source.is_none() && !matches!(n.kind, NodeKind::Instance { .. }) {
+            return;
+        }
+        let original = Value::Object(kept).to_string();
+        if original.len() > MAX_STASH {
+            return;
+        }
+        push_plugin_data(f, LAYOUT_STASH.0, LAYOUT_STASH.1, &original);
     }
 
     /// A grid layout's track gaps, order and sizes.
@@ -385,8 +445,17 @@ impl<'d> Exporter<'d> {
         };
         f.extend([("gridColumnGap", fl(l.gap)), ("gridRowGap", fl(l.cross_gap)), ("gridColumns", map(&cols)), ("gridRows", map(&rows))]);
         let mut dropped = false;
-        let column_sizing = grid_sizing(&l.column_tracks, &cols, &mut dropped);
-        let row_sizing = grid_sizing(&l.row_tracks, &rows, &mut dropped);
+        let stroke = if l.strokes_included && n.has_visible_stroke() && n.stroke.align == StrokeAlign::Inside {
+            n.stroke.sides.unwrap_or([n.stroke.weight; 4])
+        } else {
+            [0.0; 4]
+        };
+        let [pt, pr, pb, pl] = std::array::from_fn::<_, 4, _>(|i| l.padding[i] + stroke[i].max(0.0));
+        let width = (n.child_layout.sizing_h != Sizing::Hug).then_some(n.width - pl - pr - l.gap.max(0.0) * cols.len().saturating_sub(1) as f64);
+        let height =
+            (n.child_layout.sizing_v != Sizing::Hug).then_some(n.height - pt - pb - l.cross_gap.max(0.0) * rows.len().saturating_sub(1) as f64);
+        let column_sizing = grid_sizing(&l.column_tracks, &cols, width, &mut dropped);
+        let row_sizing = grid_sizing(&l.row_tracks, &rows, height, &mut dropped);
         if dropped {
             self.warn("grid tracks whose size the format can't hold were written as equal tracks");
         }
diff --git a/crates/format/src/fig/export_tests.rs b/crates/format/src/fig/export_tests.rs
--- a/crates/format/src/fig/export_tests.rs
+++ b/crates/format/src/fig/export_tests.rs
@@ -1082,6 +1082,8 @@ fn instance_grid_tracks_clear_the_mains_sizes() {
         grid_columns: 2,
         grid_rows: 1,
         column_tracks: vec![GridTrack::fixed(120.0); 2],
+        reverse: true,
+        cross_align: CrossAlign::Stretch,
         ..AutoLayout::default()
     };
     doc.node_mut(grid).unwrap().layout = Some(layout.clone());
@@ -1091,7 +1093,7 @@ fn instance_grid_tracks_clear_the_mains_sizes() {
     doc.insert(n, page, None).unwrap();
     let mut layer = doc.node(grid).unwrap().clone();
     (layer.id, layer.parent, layer.source) = (doc.alloc_id(), None, Some(grid));
-    layer.layout = Some(AutoLayout { column_tracks: Vec::new(), ..layout });
+    layer.layout = Some(AutoLayout { column_tracks: Vec::new(), reverse: false, cross_align: CrossAlign::Min, ..layout });
     doc.insert(layer, inst, None).unwrap();
     let (bytes, _) = export(&doc, None).unwrap();
     let (back, _) = import(&bytes, "File").unwrap();
@@ -1104,6 +1106,13 @@ fn instance_grid_tracks_clear_the_mains_sizes() {
     };
     assert_eq!(tracks(false), vec![GridTrack::fixed(120.0); 2]);
     assert!(tracks(true).iter().all(|t| *t == GridTrack::fill(1.0)), "{:?}", tracks(true));
+    let instance_grid = back
+        .nodes()
+        .find(|n| n.name == "Grid" && back.ancestors(n.id).iter().any(|a| matches!(back.node(*a).map(|p| &p.kind), Some(NodeKind::Instance { .. }))))
+        .unwrap();
+    let instance_layout = instance_grid.layout.as_ref().unwrap();
+    assert!(!instance_layout.reverse, "default layout extensions must not inherit the main's values");
+    assert_eq!(instance_layout.cross_align, CrossAlign::Min);
 }
 
 /// A frame's own min/max size survives whatever its parent is, not only under auto layout.
@@ -1137,6 +1146,74 @@ fn frame_size_limits_survive_without_auto_layout_parent() {
     assert_eq!(limits(&doc), expected, "page parent");
 }
 
+/// The auto layout settings the format has no name for (a reversed flow, a stretched cross axis,
+/// the wrapped lines' space-around/space-evenly, the grid tracks' sizes) survive a round trip
+/// through our own importer: the export keeps them as plugin data and the import puts them back.
+#[test]
+fn the_layout_settings_without_a_name_survive_the_round_trip() {
+    let mut doc = Document::new("File");
+    let page = doc.first_page().unwrap();
+    let id = doc.create(page, NodeKind::Frame, "Subject", 0.0, 0.0, 100.0, 100.0).unwrap();
+    let node = doc.node_mut(id).unwrap();
+    node.layout = Some(AutoLayout {
+        mode: LayoutMode::Horizontal,
+        wrap: true,
+        cross_align: CrossAlign::Stretch,
+        content_align: ContentAlign::SpaceEvenly,
+        reverse: true,
+        ..AutoLayout::default()
+    });
+    let (bytes, ew) = export(&doc, None).unwrap();
+    let (back, iw) = import(&bytes, "File").unwrap();
+    assert!(ew.is_empty() && iw.is_empty(), "export {ew:?}, import {iw:?}");
+    let l = back.nodes().find(|n| n.name == "Subject").unwrap().layout.as_ref().unwrap();
+    assert!(l.reverse);
+    assert_eq!(l.cross_align, CrossAlign::Stretch);
+    assert_eq!(l.content_align, ContentAlign::SpaceEvenly);
+}
+
+/// A grid's tracks (a fixed one, a percentage, a hug and a fill) survive the round trip too.
+#[test]
+fn the_grid_tracks_survive_the_round_trip() {
+    let mut doc = Document::new("File");
+    let page = doc.first_page().unwrap();
+    let id = doc.create(page, NodeKind::Frame, "Grid", 0.0, 0.0, 200.0, 100.0).unwrap();
+    let node = doc.node_mut(id).unwrap();
+    node.layout = Some(AutoLayout {
+        mode: LayoutMode::Grid,
+        grid_columns: 4,
+        grid_rows: 2,
+        column_tracks: vec![GridTrack::fixed(40.0), GridTrack::percent(0.25), GridTrack::hug(), GridTrack::fill(2.0)],
+        row_tracks: vec![GridTrack::fill(1.0), GridTrack::hug()],
+        ..AutoLayout::default()
+    });
+    for (inside_stroke, expected_pixels) in [(false, 37.5), (true, 31.5)] {
+        if inside_stroke {
+            let node = doc.node_mut(id).unwrap();
+            node.strokes = vec![Paint::solid(Color::BLACK)];
+            node.stroke.align = StrokeAlign::Inside;
+            node.stroke.sides = Some([4.0, 8.0, 12.0, 16.0]);
+        }
+        let (bytes, ew) = export(&doc, None).unwrap();
+        let archive = Archive::open(&bytes).unwrap();
+        let (schema, message) = decode_canvas(&archive.canvas).unwrap();
+        let message = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
+        let grid = message.get("nodeChanges").unwrap().list().iter().find(|n| n.get("name").and_then(Kv::str) == Some("Grid")).unwrap();
+        let sizes = grid.get("gridColumnsSizing").unwrap().get("entries").unwrap().list();
+        let percent = sizes[1].get("trackSize").unwrap().get("minSizing").unwrap();
+        assert_eq!(percent.get("type").and_then(Kv::str), Some("FIXED"));
+        // (200 - 20 padding - 30 gaps - optional 24 inside stroke) * 25%.
+        assert_eq!(percent.get("value").and_then(Kv::f64), Some(expected_pixels));
+        let (back, iw) = import(&bytes, "File").unwrap();
+        assert!(ew.is_empty() && iw.is_empty(), "export {ew:?}, import {iw:?}");
+        let l = back.nodes().find(|n| n.name == "Grid").unwrap().layout.as_ref().unwrap();
+        assert_eq!(l.grid_columns, 4);
+        assert_eq!(l.grid_rows, 2);
+        assert_eq!(l.column_tracks, vec![GridTrack::fixed(40.0), GridTrack::percent(0.25), GridTrack::hug(), GridTrack::fill(2.0)]);
+        assert_eq!(l.row_tracks, vec![GridTrack::fill(1.0), GridTrack::hug()]);
+    }
+}
+
 /// Link each instance's layers to its main's by position, as they are imported (the engine
 /// links them by path after loading).
 fn link(doc: &mut Document) {
@@ -1439,7 +1516,7 @@ fn grid_track_sizes_are_written_and_read_back() {
     let mut doc = Document::new("File");
     let page = doc.first_page().unwrap();
     let id = doc.create(page, NodeKind::Frame, "Row", 0.0, 0.0, 720.0, 70.0).unwrap();
-    let columns = vec![GridTrack::fixed(320.0), GridTrack::hug(), GridTrack { sizing: Sizing::Fill, value: 2.0 }, GridTrack::fixed(34.0)];
+    let columns = vec![GridTrack::fixed(320.0), GridTrack::hug(), GridTrack::fill(2.0), GridTrack::fixed(34.0)];
     doc.node_mut(id).unwrap().layout = Some(AutoLayout {
         mode: LayoutMode::Grid,
         grid_columns: 4,
diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs
--- a/crates/format/src/fig/map.rs
+++ b/crates/format/src/fig/map.rs
@@ -22,6 +22,8 @@ mod connector;
 mod document;
 mod layer;
 mod layout;
+#[cfg(test)]
+mod layout_stash_tests;
 mod paint;
 mod proto;
 mod text;
@@ -31,6 +33,16 @@ use component::OverrideScope;
 #[cfg(test)]
 use paint::paint;
 
+/// What a layer kept as plugin data under `(plugin, key)`, as the file JSON fields it holds.
+fn stashed(v: &View, (plugin, key): (&str, &str)) -> Option<Vec<(String, Value)>> {
+    let kept =
+        v.list("pluginData").iter().find(|p| p.get("pluginID").and_then(Kv::str) == Some(plugin) && p.get("key").and_then(Kv::str) == Some(key))?;
+    let value = kept.get("value").and_then(Kv::str)?;
+    let parsed: Value = serde_json::from_str(value).ok()?;
+    let Value::Object(map) = parsed else { return None };
+    Some(map.into_iter().collect())
+}
+
 type Obj = Map<String, Value>;
 
 /// Deepest nesting followed.
diff --git a/crates/format/src/fig/map/layout.rs b/crates/format/src/fig/map/layout.rs
--- a/crates/format/src/fig/map/layout.rs
+++ b/crates/format/src/fig/map/layout.rs
@@ -65,6 +65,28 @@ impl<'a> Mapper<'a> {
                 }
             }
         }
+        // The settings the format has no name for come back as plugin data (our extension of the
+        // file JSON): the export kept them there.
+        if let Some(kept) = stashed(v, crate::fig::export::LAYOUT_STASH) {
+            for (k, val) in kept {
+                // Do not let stale or arbitrary plugin data replace native layout fields.
+                let applicable = match k.as_str() {
+                    "layoutReverse" => val.is_boolean(),
+                    "counterAxisAlignItems" => o.get(&k).and_then(Value::as_str) == Some("MIN") && val.as_str() == Some("STRETCH"),
+                    "counterAxisAlignContent" => {
+                        o.get(&k).and_then(Value::as_str) != Some("SPACE_BETWEEN") && matches!(val.as_str(), Some("SPACE_AROUND" | "SPACE_EVENLY"))
+                    }
+                    "columnTracks" => {
+                        val.as_array().is_some_and(|tracks| o.get("gridColumnCount").and_then(Value::as_u64) == Some(tracks.len() as u64))
+                    }
+                    "rowTracks" => val.as_array().is_some_and(|tracks| o.get("gridRowCount").and_then(Value::as_u64) == Some(tracks.len() as u64)),
+                    _ => false,
+                };
+                if applicable {
+                    o.insert(k, val);
+                }
+            }
+        }
     }
 
     /// Track sizing maps are keyed by GUID; their order comes from the separate track list.
diff --git a/crates/format/src/fig/map/layout_stash_tests.rs b/crates/format/src/fig/map/layout_stash_tests.rs
new file mode 100644
--- /dev/null
+++ b/crates/format/src/fig/map/layout_stash_tests.rs
@@ -0,0 +1,39 @@
+//! Layout extensions cannot overwrite unrelated or subsequently edited native properties.
+
+use super::*;
+
+#[test]
+fn layout_stash_filters_keys_and_preserves_native_edits() {
+    for (cross, content, expected_cross, expected_content) in
+        [("MIN", "AUTO", "STRETCH", "SPACE_EVENLY"), ("CENTER", "SPACE_BETWEEN", "CENTER", "SPACE_BETWEEN")]
+    {
+        let stash = json!({
+            "id": "injected", "type": "TEXT", "layoutMode": "VERTICAL",
+            "layoutReverse": true, "counterAxisAlignItems": "STRETCH",
+            "counterAxisAlignContent": "SPACE_EVENLY", "columnTracks": [{"value": 99}],
+        });
+        let node = Kv::Object(vec![
+            ("stackMode", Kv::Enum("GRID")),
+            ("stackCounterAlignItems", Kv::Str(cross.into())),
+            ("stackCounterAlignContent", Kv::Str(content.into())),
+            (
+                "pluginData",
+                Kv::Array(vec![Kv::Object(vec![
+                    ("pluginID", Kv::Str("figcraft".into())),
+                    ("key", Kv::Str("layout".into())),
+                    ("value", Kv::Str(stash.to_string())),
+                ])]),
+            ),
+        ]);
+        let message = Kv::Object(vec![]);
+        let mut mapper = Mapper::new(&message, 100);
+        let mut mapped = Obj::new();
+        mapper.auto_layout(&mut mapped, &View::of(&node));
+        assert!(!mapped.contains_key("id") && !mapped.contains_key("type"));
+        assert_eq!(mapped["layoutMode"], "GRID");
+        assert_eq!(mapped["layoutReverse"], true);
+        assert_eq!(mapped["counterAxisAlignItems"], expected_cross);
+        assert_eq!(mapped["counterAxisAlignContent"], expected_content);
+        assert!(!mapped.contains_key("columnTracks"), "stale tracks cannot replace a changed native count");
+    }
+}
diff --git a/crates/format/src/rest/props.rs b/crates/format/src/rest/props.rs
--- a/crates/format/src/rest/props.rs
+++ b/crates/format/src/rest/props.rs
@@ -428,6 +428,8 @@ pub(super) fn auto_layout(v: &Value, fig_tracks: bool) -> Option<AutoLayout> {
     let count = |k: &str, d: u32| num(v, k).filter(|n| *n >= 1.0).map_or(d, |n| n.min(1000.0) as u32);
     let grid = mode == LayoutMode::Grid;
     let (columns, rows) = (count("gridColumnCount", 2), count("gridRowCount", 2));
+    let own_columns = own_grid_tracks(v, "columnTracks");
+    let own_rows = own_grid_tracks(v, "rowTracks");
     Some(AutoLayout {
         mode,
         wrap: text(v, "layoutWrap") == Some("WRAP"),
@@ -446,21 +448,52 @@ pub(super) fn auto_layout(v: &Value, fig_tracks: bool) -> Option<AutoLayout> {
             Some("CENTER") => CrossAlign::Center,
             Some("MAX") => CrossAlign::Max,
             Some("BASELINE") => CrossAlign::Baseline,
+            Some("STRETCH") => CrossAlign::Stretch,
             _ => CrossAlign::Min,
         },
         content_align: match text(v, "counterAxisAlignContent") {
             Some("SPACE_BETWEEN") => ContentAlign::SpaceBetween,
+            Some("SPACE_AROUND") => ContentAlign::SpaceAround,
+            Some("SPACE_EVENLY") => ContentAlign::SpaceEvenly,
             _ => ContentAlign::Auto,
         },
-        grid_columns: columns,
-        grid_rows: rows,
+        // The tracks a frame names are its tracks: their count is the count of the grid (the
+        // file's own count is only used when it names none).
+        grid_columns: if own_columns.is_empty() { columns } else { own_columns.len().clamp(1, 1000) as u32 },
+        grid_rows: if own_rows.is_empty() { rows } else { own_rows.len().clamp(1, 1000) as u32 },
         strokes_included: flag(v, "strokesIncludedInLayout").unwrap_or(false),
         reverse_z: flag(v, "itemReverseZIndex").unwrap_or(false),
-        column_tracks: grid_tracks(v, grid, fig_tracks, "columnTracks", "gridColumnsSizing", columns),
-        row_tracks: grid_tracks(v, grid, fig_tracks, "rowTracks", "gridRowsSizing", rows),
+        reverse: flag(v, "layoutReverse").unwrap_or(false),
+        column_tracks: if own_columns.is_empty() {
+            grid_tracks(v, grid, fig_tracks, "columnTracks", "gridColumnsSizing", columns)
+        } else {
+            own_columns
+        },
+        row_tracks: if own_rows.is_empty() { grid_tracks(v, grid, fig_tracks, "rowTracks", "gridRowsSizing", rows) } else { own_rows },
     })
 }
 
+/// The grid tracks a frame carries: `columnTracks` / `rowTracks` (our extension of the file
+/// JSON), each a fixed size in px, a share of the frame's inner size (a percentage), a hug track
+/// or a fill track with its fractional units.
+fn own_grid_tracks(v: &Value, key: &str) -> Vec<GridTrack> {
+    list(v, key)
+        .iter()
+        .filter_map(|t| {
+            if let Some(p) = num(t, "percent") {
+                return Some(GridTrack::percent((p / 100.0).clamp(0.0, 1.0)));
+            }
+            if let Some(px) = num(t, "fixed") {
+                return Some(GridTrack::fixed(px.clamp(0.0, MAX_SIZE)));
+            }
+            if t.get("hug").and_then(Value::as_bool) == Some(true) {
+                return Some(GridTrack::hug());
+            }
+            num(t, "fill").map(|fr| GridTrack::fill(fr.clamp(0.0, 1e4)))
+        })
+        .collect()
+}
+
 /// A grid axis's track sizes: from the `.fig` source when there is one, else the API's CSS
 /// `grid-template-*` string (at most `count` tracks, the axis's own).
 fn grid_tracks(v: &Value, grid: bool, fig_tracks: bool, fig_axis: &str, css_key: &str, count: u32) -> Vec<GridTrack> {
diff --git a/crates/layout/Cargo.toml b/crates/layout/Cargo.toml
--- a/crates/layout/Cargo.toml
+++ b/crates/layout/Cargo.toml
@@ -11,3 +11,6 @@ workspace = true
 [dependencies]
 figcraft-geom = { workspace = true }
 figcraft-doc = { workspace = true }
+
+[dev-dependencies]
+serde_json = { workspace = true }
diff --git a/crates/layout/src/behaviour_tests.rs b/crates/layout/src/behaviour_tests.rs
--- a/crates/layout/src/behaviour_tests.rs
+++ b/crates/layout/src/behaviour_tests.rs
@@ -19,6 +19,8 @@ use Sizing::{Fill, Fixed, Hug};
 struct Frame<'a> {
     mode: LayoutMode,
     wrap: bool,
+    /// Reverse each line's child order without mirroring its alignment.
+    reverse: bool,
     size: (f64, f64),
     sizing: (Sizing, Sizing),
     pad: [f64; 4],
@@ -26,6 +28,8 @@ struct Frame<'a> {
     cross_gap: f64,
     main: MainAlign,
     cross: CrossAlign,
+    /// How the wrapped lines share the cross axis.
+    content: ContentAlign,
     /// A visible stroke: weight, alignment and optional per-side weights.
     stroke: Option<(f64, StrokeAlign, Option<[f64; 4]>)>,
     strokes_included: bool,
@@ -38,6 +42,7 @@ struct Frame<'a> {
 const FRAME: Frame<'static> = Frame {
     mode: H,
     wrap: false,
+    reverse: false,
     size: (100.0, 100.0),
     sizing: (Fixed, Fixed),
     pad: [0.0; 4],
@@ -45,6 +50,7 @@ const FRAME: Frame<'static> = Frame {
     cross_gap: 0.0,
     main: MainAlign::Min,
     cross: CrossAlign::Min,
+    content: ContentAlign::Auto,
     stroke: None,
     strokes_included: true,
     min: (None, None),
@@ -67,6 +73,8 @@ struct Kid {
     cell: Option<(u32, u32)>,
     span: (u32, u32),
     align: (Option<CellAlign>, Option<CellAlign>),
+    /// The child's own cross-axis alignment in a flex layout.
+    align_self: Option<ItemAlign>,
 }
 
 const KID: Kid = Kid {
@@ -81,6 +89,7 @@ const KID: Kid = Kid {
     cell: None,
     span: (1, 1),
     align: (None, None),
+    align_self: None,
 };
 
 const fn kid(w: f64, h: f64) -> Kid {
@@ -88,14 +97,19 @@ const fn kid(w: f64, h: f64) -> Kid {
 }
 
 const fn fixed(v: f64) -> GridTrack {
-    GridTrack { sizing: Fixed, value: v }
+    GridTrack { sizing: Fixed, value: v, percent: false }
 }
 
 const fn fr(v: f64) -> GridTrack {
-    GridTrack { sizing: Fill, value: v }
+    GridTrack { sizing: Fill, value: v, percent: false }
 }
 
-const HUG: GridTrack = GridTrack { sizing: Hug, value: 0.0 };
+/// A track that takes this share of the frame's inner size (the CSS `percent` track).
+const fn pct(share: f64) -> GridTrack {
+    GridTrack { sizing: Fixed, value: share, percent: true }
+}
+
+const HUG: GridTrack = GridTrack { sizing: Hug, value: 0.0, percent: false };
 
 struct Case<'a> {
     rule: &'static str,
@@ -127,7 +141,8 @@ fn build(c: &Case<'_>) -> (Document, NodeId, Vec<NodeId>, FakeMeasure) {
             row_tracks: f.rows.to_vec(),
             strokes_included: f.strokes_included,
             reverse_z: false,
-            content_align: ContentAlign::Auto,
+            reverse: f.reverse,
+            content_align: f.content,
         });
         (n.child_layout.sizing_h, n.child_layout.sizing_v) = f.sizing;
         (n.child_layout.min_w, n.child_layout.min_h) = f.min;
@@ -158,6 +173,7 @@ fn build(c: &Case<'_>) -> (Document, NodeId, Vec<NodeId>, FakeMeasure) {
         (cl.grid_column, cl.grid_row) = (k.cell.map(|c| c.0), k.cell.map(|c| c.1));
         (cl.grid_column_span, cl.grid_row_span) = k.span;
         (cl.grid_align_h, cl.grid_align_v) = k.align;
+        cl.align_self = k.align_self;
         n.constraints = Constraints { horizontal: k.constraints.0, vertical: k.constraints.1 };
         ids.push(kid);
     }
@@ -751,6 +767,21 @@ fn grid_rules() {
             want: (80.0, 20.0),
             rects: &[(0.0, 0.0, 30.0, 20.0), (40.0, 0.0, 40.0, 10.0)],
         },
+        Case {
+            rule: "percentage tracks in a hugging grid size to their children's content",
+            // Neither axis has a definite parent size: columns 30 and 40 + gap 10 = 80.
+            frame: Frame { mode: Grid, sizing: (Hug, Hug), gap: 10.0, columns: &[pct(0.5), pct(0.5)], rows: &[pct(0.5)], ..FRAME },
+            kids: &[kid(30.0, 20.0), kid(40.0, 10.0)],
+            want: (80.0, 20.0),
+            rects: &[(0.0, 0.0, 30.0, 20.0), (40.0, 0.0, 40.0, 10.0)],
+        },
+        Case {
+            rule: "a percentage track participating in a spanning child also hugs its share",
+            frame: Frame { mode: Grid, sizing: (Hug, Hug), gap: 10.0, columns: &[pct(0.5), fr(1.0)], ..FRAME },
+            kids: &[Kid { size: (100.0, 20.0), span: (2, 1), ..KID }],
+            want: (100.0, 20.0),
+            rects: &[(0.0, 0.0, 100.0, 20.0)],
+        },
         Case {
             rule: "padding: the space between the frame's edge and its cells",
             frame: Frame { mode: Grid, sizing: (Hug, Hug), pad: [1.0, 2.0, 3.0, 4.0], columns: &[HUG], ..FRAME },
@@ -758,6 +789,177 @@ fn grid_rules() {
             want: (36.0, 24.0),
             rects: &[(4.0, 1.0, 30.0, 20.0)],
         },
+        Case {
+            rule: "a percentage track takes its share of the frame's inner width",
+            // 200 × 0.5 = 100.
+            frame: Frame { mode: Grid, size: (200.0, 50.0), columns: &[pct(0.5), pct(0.5)], ..FRAME },
+            kids: &[Kid { sizing: (Fill, Fixed), ..KID }, Kid { sizing: (Fill, Fixed), ..KID }],
+            want: (200.0, 50.0),
+            rects: &[(0.0, 0.0, 100.0, 20.0), (100.0, 0.0, 100.0, 20.0)],
+        },
+        Case {
+            rule: "a percentage track shares the width with the fill tracks, after the gaps",
+            // 200 - 10 gap = 190; the percentage track takes 95, the fill track the other 95.
+            frame: Frame { mode: Grid, size: (200.0, 50.0), gap: 10.0, columns: &[pct(0.5), fr(1.0)], ..FRAME },
+            kids: &[Kid { sizing: (Fill, Fixed), ..KID }, Kid { sizing: (Fill, Fixed), ..KID }],
+            want: (200.0, 50.0),
+            rects: &[(0.0, 0.0, 95.0, 20.0), (105.0, 0.0, 95.0, 20.0)],
+        },
+        Case {
+            rule: "a percentage track takes its share of the inner width, without the padding",
+            // (200 - 2×10) × 0.5 = 90.
+            frame: Frame { mode: Grid, size: (200.0, 50.0), pad: [0.0, 10.0, 0.0, 10.0], columns: &[pct(0.5), pct(0.5)], ..FRAME },
+            kids: &[Kid { sizing: (Fill, Fixed), ..KID }, Kid { sizing: (Fill, Fixed), ..KID }],
+            want: (200.0, 50.0),
+            rects: &[(10.0, 0.0, 90.0, 20.0), (100.0, 0.0, 90.0, 20.0)],
+        },
+        Case {
+            rule: "a spanning child only grows a hug column beyond the percentage column and gap",
+            // Inner tracks: 200 - 20 padding - 10 gap = 170; 85 percent + 65 hug + 10 gap = 160.
+            frame: Frame { mode: Grid, size: (200.0, 50.0), pad: [0.0, 10.0, 0.0, 10.0], gap: 10.0, columns: &[pct(0.5), HUG], ..FRAME },
+            kids: &[
+                Kid { size: (160.0, 20.0), cell: Some((0, 0)), span: (2, 1), ..KID },
+                Kid { cell: Some((1, 0)), align: (Some(CA::Max), None), ..KID },
+            ],
+            want: (200.0, 50.0),
+            rects: &[(10.0, 0.0, 160.0, 20.0), (150.0, 0.0, 20.0, 20.0)],
+        },
+        Case {
+            rule: "a spanning child only grows a hug row beyond the percentage row and gap",
+            frame: Frame { mode: Grid, size: (50.0, 200.0), pad: [10.0, 0.0, 10.0, 0.0], cross_gap: 10.0, rows: &[pct(0.5), HUG], ..FRAME },
+            kids: &[
+                Kid { size: (20.0, 160.0), cell: Some((0, 0)), span: (1, 2), ..KID },
+                Kid { cell: Some((0, 1)), align: (None, Some(CA::Max)), ..KID },
+            ],
+            want: (50.0, 200.0),
+            rects: &[(0.0, 10.0, 20.0, 160.0), (0.0, 150.0, 20.0, 20.0)],
+        },
+    ]);
+}
+
+/// A reversed flow: children appear in reverse order along the main axis, without reversing
+/// the main-axis alignment (Start still packs at the start, End still packs at the end).
+#[test]
+fn reverse_direction_rules() {
+    let two = &[kid(40.0, 20.0), kid(40.0, 20.0)];
+    check(&[
+        Case {
+            rule: "a reversed row: the children are laid out in the opposite order",
+            // The children keep their layer order but swap places: the second one comes first.
+            frame: Frame { reverse: true, size: (100.0, 100.0), ..FRAME },
+            kids: two,
+            want: (100.0, 100.0),
+            rects: &[(40.0, 0.0, 40.0, 20.0), (0.0, 0.0, 40.0, 20.0)],
+        },
+        Case {
+            rule: "a reversed row keeps the gap between the children",
+            // The children swap places; the gap of 10 stays between them.
+            frame: Frame { reverse: true, size: (100.0, 100.0), gap: 10.0, ..FRAME },
+            kids: two,
+            want: (100.0, 100.0),
+            rects: &[(50.0, 0.0, 40.0, 20.0), (0.0, 0.0, 40.0, 20.0)],
+        },
+        Case {
+            rule: "a reversed row keeps the main alignment: the flow still starts at the main axis' start",
+            // "max" packs the children at the end: 100 - 80 = 20, so 20 and 60.
+            frame: Frame { reverse: true, size: (100.0, 100.0), main: MainAlign::Max, ..FRAME },
+            kids: two,
+            want: (100.0, 100.0),
+            rects: &[(60.0, 0.0, 40.0, 20.0), (20.0, 0.0, 40.0, 20.0)],
+        },
+        Case {
+            rule: "a reversed column: child order reverses while alignment stays at the start",
+            frame: Frame { mode: V, reverse: true, size: (100.0, 100.0), ..FRAME },
+            kids: two,
+            want: (100.0, 100.0),
+            rects: &[(0.0, 20.0, 40.0, 20.0), (0.0, 0.0, 40.0, 20.0)],
+        },
+        Case {
+            rule: "a reversed row with auto space between: the free space goes between the children",
+            // The auto gap is not mirrored: the space still sits between the two children.
+            frame: Frame { reverse: true, size: (100.0, 100.0), main: MainAlign::SpaceBetween, ..FRAME },
+            kids: two,
+            want: (100.0, 100.0),
+            rects: &[(60.0, 0.0, 40.0, 20.0), (0.0, 0.0, 40.0, 20.0)],
+        },
+        Case {
+            rule: "a reversed row wraps like a normal one: the lines fill from the start",
+            // Row 1: the first two children (reversed); row 2: the third.
+            frame: Frame { reverse: true, wrap: true, size: (100.0, 100.0), gap: 10.0, cross_gap: 5.0, ..FRAME },
+            kids: &[kid(40.0, 20.0), kid(40.0, 20.0), kid(40.0, 20.0)],
+            want: (100.0, 100.0),
+            rects: &[(50.0, 0.0, 40.0, 20.0), (0.0, 0.0, 40.0, 20.0), (0.0, 25.0, 40.0, 20.0)],
+        },
+    ]);
+}
+
+#[test]
+fn percentage_grid_tracks_do_not_reverse_when_gaps_exceed_the_frame() {
+    check(&[Case {
+        rule: "oversized gaps leave percentage tracks with zero space",
+        frame: Frame {
+            mode: LayoutMode::Grid,
+            size: (10.0, 10.0),
+            gap: 30.0,
+            cross_gap: 30.0,
+            columns: &[pct(0.5), pct(0.5)],
+            rows: &[pct(0.5), pct(0.5)],
+            ..FRAME
+        },
+        kids: &[kid(0.0, 0.0), kid(0.0, 0.0), kid(0.0, 0.0), kid(0.0, 0.0)],
+        want: (10.0, 10.0),
+        rects: &[(0.0, 0.0, 0.0, 0.0), (30.0, 0.0, 0.0, 0.0), (0.0, 30.0, 0.0, 0.0), (30.0, 30.0, 0.0, 0.0)],
+    }]);
+}
+
+/// A stretched cross axis: the children that don't ask for an alignment of their own take the
+/// line's cross size.
+#[test]
+fn stretch_cross_axis_rules() {
+    check(&[
+        Case {
+            rule: "a stretched row: the children take the frame's height",
+            // The children are packed at the start of the main axis: 0 and 40 (no gap).
+            frame: Frame { size: (100.0, 60.0), cross: CrossAlign::Stretch, ..FRAME },
+            kids: &[kid(40.0, 20.0), kid(40.0, 20.0)],
+            want: (100.0, 60.0),
+            rects: &[(0.0, 0.0, 40.0, 60.0), (40.0, 0.0, 40.0, 60.0)],
+        },
+        Case {
+            rule: "a fixed-width stretched row hugs the tallest child's natural height",
+            frame: Frame { size: (100.0, 90.0), sizing: (Fixed, Hug), cross: CrossAlign::Stretch, ..FRAME },
+            kids: &[kid(40.0, 20.0), kid(40.0, 35.0)],
+            want: (100.0, 35.0),
+            rects: &[(0.0, 0.0, 40.0, 35.0), (40.0, 0.0, 40.0, 35.0)],
+        },
+        Case {
+            rule: "a stretched row hugs natural heights when children fill the main axis",
+            frame: Frame { size: (100.0, 90.0), sizing: (Fixed, Hug), cross: CrossAlign::Stretch, ..FRAME },
+            kids: &[Kid { size: (40.0, 20.0), sizing: (Fill, Fixed), ..KID }, kid(40.0, 35.0)],
+            want: (100.0, 35.0),
+            rects: &[(0.0, 0.0, 60.0, 35.0), (60.0, 0.0, 40.0, 35.0)],
+        },
+        Case {
+            rule: "a stretched column: the children take the frame's width",
+            frame: Frame { mode: V, size: (60.0, 100.0), cross: CrossAlign::Stretch, ..FRAME },
+            kids: &[kid(40.0, 20.0), kid(40.0, 20.0)],
+            want: (60.0, 100.0),
+            rects: &[(0.0, 0.0, 60.0, 20.0), (0.0, 20.0, 60.0, 20.0)],
+        },
+        Case {
+            rule: "a stretched row inside the padding: the children take the inner height",
+            frame: Frame { size: (100.0, 60.0), pad: [10.0; 4], cross: CrossAlign::Stretch, ..FRAME },
+            kids: &[kid(40.0, 20.0)],
+            want: (100.0, 60.0),
+            rects: &[(10.0, 10.0, 40.0, 40.0)],
+        },
+        Case {
+            rule: "a child with its own alignment keeps it: it is not stretched",
+            frame: Frame { size: (100.0, 60.0), cross: CrossAlign::Stretch, ..FRAME },
+            kids: &[Kid { sizing: (Fixed, Fixed), align_self: Some(ItemAlign::Center), ..KID }],
+            want: (100.0, 60.0),
+            rects: &[(0.0, 20.0, 20.0, 20.0)],
+        },
     ]);
 }
 
diff --git a/crates/layout/src/content_align_tests.rs b/crates/layout/src/content_align_tests.rs
--- a/crates/layout/src/content_align_tests.rs
+++ b/crates/layout/src/content_align_tests.rs
@@ -5,7 +5,7 @@
 use super::*;
 use figcraft_doc::{Document, NodeKind};
 
-use ContentAlign::{Auto, SpaceBetween as Between};
+use ContentAlign::{Auto, SpaceAround as Around, SpaceBetween as Between, SpaceEvenly as Evenly};
 use CrossAlign::{Center, Max, Min};
 
 struct Case<'a> {
@@ -169,6 +169,50 @@ fn wrapped_lines_across_the_flow() {
             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
         },
+        Case {
+            // 100 - 40 (two rows of 20) = 60 free: around gives each line 30, half of it at
+            // each edge (15 above the first row, 15 below the last).
+            rule: "space around: the free space is shared by the lines, half of it at each edge",
+            cross_gap: 0.0,
+            content: Around,
+            want: &[(0.0, 15.0, 40.0, 20.0), (50.0, 15.0, 40.0, 20.0), (0.0, 65.0, 40.0, 20.0)],
+            ..CASE
+        },
+        Case {
+            // 60 free over three gaps (two between the lines and one at each edge): 20 each.
+            rule: "space evenly: the free space is shared by the lines and the edges alike",
+            cross_gap: 0.0,
+            content: Evenly,
+            want: &[(0.0, 20.0, 40.0, 20.0), (50.0, 20.0, 40.0, 20.0), (0.0, 60.0, 40.0, 20.0)],
+            ..CASE
+        },
+        Case {
+            rule: "space around, vertical wrap: the columns share the width, half of it at each edge",
+            vertical: true,
+            cross_gap: 0.0,
+            content: Around,
+            kids: &[(20.0, 40.0), (20.0, 40.0), (20.0, 40.0)],
+            want: &[(15.0, 0.0, 20.0, 40.0), (15.0, 50.0, 20.0, 40.0), (65.0, 0.0, 20.0, 40.0)],
+            ..CASE
+        },
+        Case {
+            rule: "space evenly, vertical wrap: the columns and the edges share the width alike",
+            vertical: true,
+            cross_gap: 0.0,
+            content: Evenly,
+            kids: &[(20.0, 40.0), (20.0, 40.0), (20.0, 40.0)],
+            want: &[(20.0, 0.0, 20.0, 40.0), (20.0, 50.0, 20.0, 40.0), (60.0, 0.0, 20.0, 40.0)],
+            ..CASE
+        },
+        Case {
+            rule: "space around with one row: the row keeps its place, the free space is all at the edge",
+            size: (200.0, 100.0),
+            cross_gap: 0.0,
+            content: Around,
+            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
+        },
     ];
     for c in &cases {
         let got = run(c);
diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs
--- 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, ContentAlign, CrossAlign, Document, GridAlign, GridKind, HConstraint, LayoutGrid, LayoutMode, MainAlign, NodeId, NodeKind,
-    Sizing, StrokeAlign, TextAutoResize, VConstraint,
+    AutoLayout, CellAlign, ContentAlign, CrossAlign, Document, GridAlign, GridKind, HConstraint, ItemAlign, LayoutGrid, LayoutMode, MainAlign,
+    NodeId, NodeKind, Sizing, StrokeAlign, TextAutoResize, VConstraint,
 };
 use figcraft_geom::{Affine, Point, Rect};
 
@@ -220,7 +220,9 @@ fn coherent_own_sizing(doc: &mut Document, id: NodeId) -> bool {
 fn insets(f: &figcraft_doc::Node, al: &AutoLayout) -> (f64, f64, f64, f64) {
     let included = al.strokes_included && f.has_visible_stroke() && f.stroke.align == StrokeAlign::Inside;
     let stroke = if included { f.stroke.sides.unwrap_or([f.stroke.weight; 4]) } else { [0.0; 4] };
-    let [pt, pr, pb, pl] = al.padding.map(|p| finite_or_zero(p).max(0.0));
+    // Preserve signed padding. Negative padding legitimately shifts the content outside the
+    // frame; clamp each value to a safe finite bound, not to zero.
+    let [pt, pr, pb, pl] = al.padding.map(|p| finite_or_zero(p).clamp(-100_000.0, 100_000.0));
     let [st, sr, sb, sl] = stroke.map(|w| finite_or_zero(w).max(0.0));
     (pt + st, pr + sr, pb + sb, pl + sl)
 }
@@ -331,6 +333,26 @@ struct Item {
     baseline: Option<f64>,
     /// Baseline alignment: offset from the line's top (computed per line).
     base_off: f64,
+    margin_main_before: f64,
+    margin_main_after: f64,
+    margin_cross_before: f64,
+    margin_cross_after: f64,
+    align_self: Option<ItemAlign>,
+    /// The frame stretches its children across the cross axis and this child doesn't ask for an
+    /// alignment of its own: it takes the line's cross size.
+    stretch_cross: bool,
+}
+
+impl Item {
+    fn outer_main(&self) -> f64 {
+        self.margin_main_before + self.main + self.margin_main_after
+    }
+    fn outer_cross(&self) -> f64 {
+        self.margin_cross_before + self.cross + self.margin_cross_after
+    }
+    fn stretches(&self) -> bool {
+        self.fill_cross || self.align_self == Some(ItemAlign::Stretch) || self.stretch_cross
+    }
 }
 
 /// Lay out one auto layout frame's children and size the frame if it hugs.
@@ -362,7 +384,12 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {
     let inner_cross_fixed = if horizontal { fh - pt - pb } else { fw - pl - pr };
     let hug_main = if horizontal { hug_w } else { hug_h };
     let hug_cross = if horizontal { hug_h } else { hug_w };
-    let baseline = horizontal && al.cross_align == CrossAlign::Baseline;
+    let baseline = horizontal
+        && (al.cross_align == CrossAlign::Baseline
+            || kids.iter().any(|id| doc.node(*id).is_some_and(|n| n.child_layout.align_self == Some(ItemAlign::Baseline))));
+    // A frame that stretches its children across the cross axis: the children that don't ask for
+    // an alignment of their own take the line's cross size.
+    let stretch_cross = al.cross_align == CrossAlign::Stretch;
 
     let mut items: Vec<Item> = Vec::with_capacity(kids.len());
     for c in &kids {
@@ -372,13 +399,23 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {
         let (sm, sc) = if horizontal { (cl.sizing_h, cl.sizing_v) } else { (cl.sizing_v, cl.sizing_h) };
         let (min_main, max_main, min_cross, max_cross) =
             if horizontal { (cl.min_w, cl.max_w, cl.min_h, cl.max_h) } else { (cl.min_h, cl.max_h, cl.min_w, cl.max_w) };
+        let [mt, mr, mb, ml] = cl.margin.map(|v| finite_or_zero(v).clamp(-100_000.0, 100_000.0));
+        let (margin_main_before, margin_main_after, margin_cross_before, margin_cross_after) =
+            if horizontal { (ml, mr, mt, mb) } else { (mt, mb, ml, mr) };
         items.push(Item {
             id: *c,
+            margin_main_before,
+            margin_main_after,
+            margin_cross_before,
+            margin_cross_after,
+            align_self: cl.align_self,
             main: if horizontal { w } else { h },
             cross: if horizontal { h } else { w },
             // Fill needs room to fill: on an axis the frame hugs it reads as fixed.
             fill_main: sm == Sizing::Fill && !hug_main,
             fill_cross: sc == Sizing::Fill && !hug_cross,
+            // A child that asks for an alignment of its own keeps it; the rest stretch.
+            stretch_cross: stretch_cross && cl.align_self.is_none(),
             min_main,
             max_main,
             min_cross,
@@ -394,9 +431,9 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {
 
     // Fill across a single line: the size is the frame's inner cross size, known up front. Resize
     // those children first, so text that wraps (or a frame hugging it) reports its final main size.
-    if !wraps {
-        for it in items.iter_mut().filter(|it| it.fill_cross && !it.fill_main) {
-            let cross = clamp_size(inner_cross_fixed, it.min_cross, it.max_cross);
+    if !wraps && !hug_cross {
+        for it in items.iter_mut().filter(|it| it.stretches() && !it.fill_main) {
+            let cross = clamp_size(inner_cross_fixed - it.margin_cross_before - it.margin_cross_after, it.min_cross, it.max_cross);
             let (w, h) = if horizontal { (it.main, cross) } else { (cross, it.main) };
             resize_child(doc, it.id, w, h, m);
             let (w, h) = outer_size(doc, it.id);
@@ -410,7 +447,7 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {
         let mut cur: Vec<usize> = Vec::new();
         let mut used = 0.0;
         for (i, it) in items.iter().enumerate() {
-            let sz = if it.fill_main { it.min_main.unwrap_or(0.0) } else { it.main };
+            let sz = it.margin_main_before + (if it.fill_main { it.min_main.unwrap_or(0.0) } else { it.main }) + it.margin_main_after;
             let add = if cur.is_empty() { sz } else { gap.max(0.0) + sz };
             if !cur.is_empty() && used + add > inner_main_fixed + 1e-6 {
                 lines.push(std::mem::take(&mut cur));
@@ -429,7 +466,11 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {
 
     // Share each line's free space among its fill children.
     for line in &lines {
-        let fixed: f64 = line.iter().filter_map(|i| items.get(*i)).filter(|it| !it.fill_main).map(|it| it.main).sum();
+        let fixed: f64 = line
+            .iter()
+            .filter_map(|i| items.get(*i))
+            .map(|it| it.margin_main_before + it.margin_main_after + (if it.fill_main { 0.0 } else { it.main }))
+            .sum();
         let gaps = gap * line.len().saturating_sub(1) as f64;
         let fills: Vec<usize> = line.iter().copied().filter(|i| items.get(*i).is_some_and(|it| it.fill_main)).collect();
         if !fills.is_empty() {
@@ -439,15 +480,23 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {
     // Fill children take their main size now, so their cross size (wrapped text, a hugging frame)
     // is final before the lines and the frame are sized.
     for it in items.iter_mut().filter(|it| it.fill_main) {
-        let cross = if it.fill_cross && !wraps { clamp_size(inner_cross_fixed, it.min_cross, it.max_cross) } else { it.cross };
+        let cross = if it.stretches() && !wraps && !hug_cross {
+            clamp_size(inner_cross_fixed - it.margin_cross_before - it.margin_cross_after, it.min_cross, it.max_cross)
+        } else {
+            it.cross
+        };
         let (w, h) = if horizontal { (it.main, cross) } else { (cross, it.main) };
         resize_child(doc, it.id, w, h, m);
         let (w, h) = outer_size(doc, it.id);
         it.cross = if horizontal { h } else { w };
     }
     if baseline {
-        for it in items.iter_mut().filter(|it| !it.fill_cross) {
-            it.baseline = item_baseline(doc, it.id, m, 0);
+        for it in items.iter_mut().filter(|it| !it.stretches()) {
+            it.baseline = item_baseline(doc, it.id, m, 0).filter(|_| match it.align_self {
+                Some(ItemAlign::Baseline) => true,
+                Some(_) => false,
+                None => al.cross_align == CrossAlign::Baseline,
+            });
         }
     }
 
@@ -456,17 +505,19 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {
     for line in &lines {
         let gaps = gap * line.len().saturating_sub(1) as f64;
         // Baseline alignment: the line's baseline sits at the deepest first baseline.
-        let line_base = line.iter().filter_map(|i| items.get(*i)).filter_map(|it| it.baseline).fold(0.0, f64::max);
+        let line_base =
+            line.iter().filter_map(|i| items.get(*i)).filter_map(|it| it.baseline.map(|b| b + it.margin_cross_before)).fold(0.0, f64::max);
         for i in line {
             if let Some(it) = items.get_mut(*i) {
-                it.base_off = it.baseline.map(|b| line_base - b).unwrap_or(0.0);
+                it.base_off = it.baseline.map(|b| line_base - b - it.margin_cross_before).unwrap_or(0.0);
             }
         }
         let in_line = || line.iter().filter_map(|i| items.get(*i));
-        let main_used: f64 = in_line().map(|it| it.main).sum::<f64>() + gaps;
+        let main_used: f64 = in_line().map(Item::outer_main).sum::<f64>() + gaps;
         // A line of nothing but fill-across children keeps their own size.
-        let all_fill = in_line().all(|it| it.fill_cross);
-        let cross: f64 = in_line().filter(|it| all_fill || !it.fill_cross).map(|it| it.base_off + it.cross).fold(0.0, f64::max);
+        let all_fill = in_line().all(Item::stretches);
+        let cross: f64 =
+            in_line().filter(|it| hug_cross || all_fill || !it.stretches()).map(|it| (it.base_off + it.outer_cross()).max(0.0)).fold(0.0, f64::max);
         line_extents.push((main_used, cross));
     }
 
@@ -499,30 +550,52 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {
 
     // 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.
+    // space goes between them, with half as much at each edge (around) or as much as between
+    // them (evenly).
     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;
+        // Where the lines sit when they don't fill the cross axis: packed at the start, centred
+        // or packed at the end.
+        let packed = |free: f64| match al.cross_align {
+            CrossAlign::Min | CrossAlign::Baseline | CrossAlign::Stretch => 0.0,
+            CrossAlign::Center => free / 2.0,
+            CrossAlign::Max => free,
+        };
+        match al.content_align {
+            ContentAlign::SpaceBetween => {
+                let spread = (inner_cross - lines_cross) / between;
+                if spread > 0.0 {
+                    cross_gap = spread;
+                } else {
+                    // No room to spread: the lines touch and sit where the alignment says.
+                    cross_gap = 0.0;
+                    cross_pos += packed(inner_cross - lines_cross);
+                }
             }
-            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,
-                };
+            ContentAlign::SpaceAround => {
+                // Each line gets an equal share of the free space; half of it sits at each edge.
+                let share = (free / lines.len() as f64).max(0.0);
+                cross_gap += share;
+                cross_pos += share / 2.0;
+            }
+            ContentAlign::SpaceEvenly => {
+                // The free space is shared equally between the lines and at each edge.
+                let share = (free / (lines.len() as f64 + 1.0)).max(0.0);
+                cross_gap += share;
+                cross_pos += share;
+            }
+            ContentAlign::Auto => {
+                if free > 0.0 && items.iter().all(Item::stretches) {
+                    let extra = free / lines.len() as f64;
+                    line_extents.iter_mut().for_each(|l| l.1 += extra);
+                } else {
+                    cross_pos += packed(free);
+                }
             }
         }
     }
@@ -530,26 +603,44 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {
         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 };
         let total = used - gap * line.len().saturating_sub(1) as f64;
+        // A reversed flow lays the line's children out in the opposite order along the main axis.
+        // The main-axis alignment does not mirror: the flow still starts at the main axis' start.
         let (mut pos, step_gap) = main_offsets(al.main_align, inner_main, total, line.len(), gap);
         pos += if horizontal { pl } else { pt };
-        for i in line {
+        let order: Vec<usize> = if al.reverse { line.iter().rev().copied().collect() } else { line.clone() };
+        for i in &order {
             let Some(it) = items.get(*i) else { continue };
-            let cross_size = if it.fill_cross { clamp_size(line_cross, it.min_cross, it.max_cross) } else { it.cross };
-            let off = match al.cross_align {
-                CrossAlign::Min => 0.0,
-                // Baseline applies to horizontal layouts; elsewhere it reads as top/left.
-                CrossAlign::Baseline => it.base_off,
-                CrossAlign::Center => (line_cross - cross_size) / 2.0,
-                CrossAlign::Max => line_cross - cross_size,
+            let cross_size = if it.stretches() {
+                clamp_size(line_cross - it.margin_cross_before - it.margin_cross_after, it.min_cross, it.max_cross)
+            } else {
+                it.cross
             };
-            // Fill across a wrapped line: its size is known only now.
-            if it.fill_cross && wraps {
+            let free_cross = line_cross - cross_size - it.margin_cross_before - it.margin_cross_after;
+            let alignment = it.align_self.unwrap_or(match al.cross_align {
+                CrossAlign::Min => ItemAlign::Min,
+                CrossAlign::Center => ItemAlign::Center,
+                CrossAlign::Max => ItemAlign::Max,
+                CrossAlign::Baseline => ItemAlign::Baseline,
+                CrossAlign::Stretch => ItemAlign::Stretch,
+            });
+            let off = match alignment {
+                ItemAlign::Min | ItemAlign::Stretch => 0.0,
+                ItemAlign::Baseline => it.base_off,
+                ItemAlign::Center => free_cross / 2.0,
+                ItemAlign::Max => free_cross,
+            };
+            // Resize cross-axis fill and stretch children after resolving the line size.
+            if it.stretches() && (wraps || hug_cross) {
                 let (w, h) = if horizontal { (it.main, cross_size) } else { (cross_size, it.main) };
                 resize_child(doc, it.id, w, h, m);
             }
-            let (x, y) = if horizontal { (pos, cross_pos + off) } else { (cross_pos + off, pos) };
+            let (x, y) = if horizontal {
+                (pos + it.margin_main_before, cross_pos + it.margin_cross_before + off)
+            } else {
+                (cross_pos + it.margin_cross_before + off, pos + it.margin_main_before)
+            };
             place(doc, it.id, x, y);
-            pos += it.main + step_gap;
+            pos += it.outer_main() + step_gap;
         }
         cross_pos += line_cross + cross_gap;
     }
@@ -725,21 +816,45 @@ fn track_spec(specs: &[figcraft_doc::GridTrack], i: usize) -> (Sizing, f64) {
     (t.sizing, v)
 }
 
-/// Resolve track sizes: fixed tracks keep their size, hug tracks fit their content, fill tracks
-/// share what is left by their fractional units like the CSS `fr` unit (a track never gets less
-/// than its content: one whose share is too small keeps its content size and the others share the
-/// rest). `avail` is None when the frame hugs that axis (fill tracks then hug their content too).
+/// Resolve track sizes: fixed tracks keep their size, percentage tracks take their share of the
+/// frame's inner size, hug tracks fit their content, fill tracks share what is left by their
+/// fractional units like the CSS `fr` unit (a track never gets less than its content: one whose
+/// share is too small keeps its content size and the others share the rest). `avail` is None when
+/// the frame hugs that axis (fill tracks then hug their content too).
 fn resolve_tracks(specs: &[figcraft_doc::GridTrack], content: &[f64], avail: Option<f64>) -> Vec<f64> {
     let mut out: Vec<f64> = (0..content.len())
-        .map(|i| match track_spec(specs, i) {
-            (Sizing::Fixed, v) => v,
-            _ => content.get(i).copied().unwrap_or(0.0),
+        .map(|i| {
+            let t = specs.get(i).copied().unwrap_or_default();
+            if t.percent {
+                // A percentage track needs the frame's size; without it, it hugs its content.
+                if avail.is_none() { content.get(i).copied().unwrap_or(0.0) } else { 0.0 }
+            } else {
+                match track_spec(specs, i) {
+                    (Sizing::Fixed, value) => value,
+                    _ => content.get(i).copied().unwrap_or(0.0),
+                }
+            }
         })
         .collect();
     let Some(a) = avail else { return out };
+    // Percentage tracks take their share of the frame's inner size (the gaps are already out of
+    // it), like CSS `percent`.
+    for i in 0..out.len() {
+        let t = specs.get(i).copied().unwrap_or_default();
+        if t.percent {
+            let share = if t.value.is_finite() { t.value.clamp(0.0, 1.0) } else { 0.0 };
+            if let Some(s) = out.get_mut(i) {
+                *s = a * share;
+            }
+        }
+    }
     let fr = |i: usize| track_spec(specs, i).1;
     let base = |out: &[f64], i: usize| out.get(i).copied().unwrap_or(0.0);
-    let (mut flex, fixed): (Vec<usize>, Vec<usize>) = (0..out.len()).partition(|i| track_spec(specs, *i).0 == Sizing::Fill);
+    let is_flex = |i: usize| {
+        let t = specs.get(i).copied().unwrap_or_default();
+        t.sizing == Sizing::Fill && !t.percent
+    };
+    let (mut flex, fixed): (Vec<usize>, Vec<usize>) = (0..out.len()).partition(|i| is_flex(*i));
     let mut used: f64 = fixed.iter().map(|i| base(&out, *i)).sum();
     // Each round either sizes every flexible track or freezes at least one at its content size.
     while !flex.is_empty() {
@@ -764,9 +879,34 @@ fn resolve_tracks(specs: &[figcraft_doc::GridTrack], content: &[f64], avail: Opt
 /// largest single-span child (for a fill track, the least it can shrink to); a spanning child that
 /// needs more than its tracks give it grows the content-sized tracks it spans (unless a flexible
 /// track can absorb it).
-fn track_content(cells: &[GridCell], sizes: &[f64], specs: &[figcraft_doc::GridTrack], n: usize, gap: f64, columns: bool, hug: bool) -> Vec<f64> {
-    let by_content = |i: usize| track_spec(specs, i).0 != Sizing::Fixed;
-    let mut content: Vec<f64> = (0..n).map(|i| if let (Sizing::Fixed, v) = track_spec(specs, i) { v } else { 0.0 }).collect();
+fn track_content(
+    cells: &[GridCell],
+    sizes: &[f64],
+    specs: &[figcraft_doc::GridTrack],
+    n: usize,
+    gap: f64,
+    columns: bool,
+    avail: Option<f64>,
+) -> Vec<f64> {
+    let hug = avail.is_none();
+    let by_content = |i: usize| {
+        let t = specs.get(i).copied().unwrap_or_default();
+        (t.percent && hug) || (!t.percent && t.sizing != Sizing::Fixed)
+    };
+    let mut content: Vec<f64> = (0..n)
+        .map(|i| {
+            let t = specs.get(i).copied().unwrap_or_default();
+            if t.percent {
+                // A definite percentage already contributes to every span crossing this track.
+                let share = if t.value.is_finite() { t.value.clamp(0.0, 1.0) } else { 0.0 };
+                avail.unwrap_or(0.0) * share
+            } else if let (Sizing::Fixed, v) = track_spec(specs, i) {
+                v
+            } else {
+                0.0
+            }
+        })
+        .collect();
     let span = |c: &GridCell| if columns { (c.column, c.column_span) } else { (c.row, c.row_span) };
     for (c, s) in cells.iter().zip(sizes) {
         let (start, len) = span(c);
@@ -779,7 +919,13 @@ fn track_content(cells: &[GridCell], sizes: &[f64], specs: &[figcraft_doc::GridT
     }
     for (c, s) in cells.iter().zip(sizes) {
         let (start, len) = span(c);
-        if len < 2 || (!hug && (start..start + len).any(|i| track_spec(specs, i).0 == Sizing::Fill)) {
+        if len < 2
+            || (!hug
+                && (start..start + len).any(|i| {
+                    let t = specs.get(i).copied().unwrap_or_default();
+                    t.sizing == Sizing::Fill && !t.percent
+                }))
+        {
             continue;
         }
         let have: f64 = content.iter().skip(start).take(len).sum::<f64>() + gap * (len - 1) as f64;
@@ -828,15 +974,16 @@ pub fn grid_geometry(doc: &Document, frame: NodeId) -> Option<GridGeometry> {
     for c in &cells {
         let (w, h) = outer_size(doc, c.id);
         let cl = doc.node(c.id).map(|n| n.child_layout.clone()).unwrap_or_default();
-        ws.push(if cl.sizing_h == Sizing::Fill && !hug_w { cl.min_w.unwrap_or(0.0) } else { w });
-        hs.push(if cl.sizing_v == Sizing::Fill && !hug_h { cl.min_h.unwrap_or(0.0) } else { h });
+        let [mt, mr, mb, ml] = cl.margin.map(|v| finite_or_zero(v).clamp(-100_000.0, 100_000.0));
+        ws.push((if cl.sizing_h == Sizing::Fill && !hug_w { cl.min_w.unwrap_or(0.0) } else { w }) + ml + mr);
+        hs.push((if cl.sizing_v == Sizing::Fill && !hug_h { cl.min_h.unwrap_or(0.0) } else { h }) + mt + mb);
     }
-    let col_content = track_content(&cells, &ws, &al.column_tracks, cols, gx, true, hug_w);
-    let row_content = track_content(&cells, &hs, &al.row_tracks, rows, gy, false, hug_h);
     let gaps_w = gx * cols.saturating_sub(1) as f64;
     let gaps_h = gy * rows.saturating_sub(1) as f64;
-    let avail_w = (!hug_w).then_some(f.width - pl - pr - gaps_w);
-    let avail_h = (!hug_h).then_some(f.height - pt - pb - gaps_h);
+    let avail_w = (!hug_w).then_some((f.width - pl - pr - gaps_w).max(0.0));
+    let avail_h = (!hug_h).then_some((f.height - pt - pb - gaps_h).max(0.0));
+    let col_content = track_content(&cells, &ws, &al.column_tracks, cols, gx, true, avail_w);
+    let row_content = track_content(&cells, &hs, &al.row_tracks, rows, gy, false, avail_h);
     let col_w = resolve_tracks(&al.column_tracks, &col_content, avail_w);
     let row_h = resolve_tracks(&al.row_tracks, &row_content, avail_h);
     let cl = &f.child_layout;
@@ -870,7 +1017,9 @@ fn layout_grid(doc: &mut Document, frame: NodeId, al: &AutoLayout, m: &dyn Measu
         let Some(n) = doc.node(c.id) else { continue };
         let cl = &n.child_layout;
         if cl.sizing_h == Sizing::Fill && !hug_w {
-            let nw = clamp_size(g.cell_rect(c).width(), cl.min_w, cl.max_w);
+            let right = finite_or_zero(cl.margin[1]).clamp(-100_000.0, 100_000.0);
+            let left = finite_or_zero(cl.margin[3]).clamp(-100_000.0, 100_000.0);
+            let nw = clamp_size(g.cell_rect(c).width() - left - right, cl.min_w, cl.max_w);
             let (_, h) = outer_size(doc, c.id);
             resize_child(doc, c.id, nw, h, m);
             widened = true;
@@ -902,15 +1051,18 @@ fn layout_grid(doc: &mut Document, frame: NodeId, al: &AutoLayout, m: &dyn Measu
         let fill_w = cl.sizing_h == Sizing::Fill && !hug_w;
         let fill_h = cl.sizing_v == Sizing::Fill && !hug_h;
         let (w, h) = outer_size(doc, c.id);
-        let nw = if fill_w { clamp_size(cell.width(), cl.min_w, cl.max_w) } else { w };
-        let nh = if fill_h { clamp_size(cell.height(), cl.min_h, cl.max_h) } else { h };
+        let [mt, mr, mb, ml] = cl.margin.map(|v| finite_or_zero(v).clamp(-100_000.0, 100_000.0));
+        let inner_w = cell.width() - ml - mr;
+        let inner_h = cell.height() - mt - mb;
+        let nw = if fill_w { clamp_size(inner_w, cl.min_w, cl.max_w) } else { w };
+        let nh = if fill_h { clamp_size(inner_h, cl.min_h, cl.max_h) } else { h };
         if fill_w || fill_h {
             resize_child(doc, c.id, nw, nh, m);
         }
         let (nw, nh) = outer_size(doc, c.id);
-        let ox = offset(cl.grid_align_h.unwrap_or(frame_h), cell.width() - nw);
-        let oy = offset(cl.grid_align_v.unwrap_or(frame_v), cell.height() - nh);
-        place(doc, c.id, cell.x0 + ox, cell.y0 + oy);
+        let ox = offset(cl.grid_align_h.unwrap_or(frame_h), inner_w - nw);
+        let oy = offset(cl.grid_align_v.unwrap_or(frame_v), inner_h - nh);
+        place(doc, c.id, cell.x0 + ml + ox, cell.y0 + mt + oy);
     }
 }
 
diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs
--- a/crates/layout/src/tests.rs
+++ b/crates/layout/src/tests.rs
@@ -421,7 +421,8 @@ fn grid_hostile_values() {
     let (mut d, f) = grid_frame(0, f64::NAN, -5.0);
     {
         let l = d.get_mut(f).unwrap().layout.as_mut().unwrap();
-        l.column_tracks = vec![GridTrack { sizing: Sizing::Fixed, value: f64::NAN }, GridTrack::fill(f64::INFINITY), GridTrack::fill(-3.0)];
+        l.column_tracks =
+            vec![GridTrack { sizing: Sizing::Fixed, value: f64::NAN, percent: false }, GridTrack::fill(f64::INFINITY), GridTrack::fill(-3.0)];
         l.row_tracks = vec![GridTrack::fixed(1e300)];
         l.grid_rows = u32::MAX;
     }
@@ -447,3 +448,122 @@ fn grid_hostile_values() {
         assert!(x.is_finite() && y.is_finite() && w.is_finite() && h.is_finite());
     }
 }
+
+// Penpot V9: previously these values survived only in plugin metadata. All of them must now
+// contribute to native layout and survive future frame resizes.
+#[test]
+fn item_margins_and_align_self_in_horizontal_flow() {
+    use figcraft_doc::ItemAlign;
+    let (mut d, f, k) = setup(LayoutMode::Horizontal);
+    d.get_mut(k[0]).unwrap().child_layout.margin = [2.0, 7.0, 3.0, 10.0];
+    d.get_mut(k[0]).unwrap().child_layout.align_self = Some(ItemAlign::Max);
+    d.get_mut(k[1]).unwrap().child_layout.margin = [1.0, 0.0, 0.0, 0.0];
+    layout_frame(&mut d, f, &NoMeasure);
+    assert_eq!(pos(&d, k[0]), (15.0, 72.0, 50.0, 20.0));
+    assert_eq!(pos(&d, k[1]), (82.0, 6.0, 30.0, 40.0));
+}
+
+#[test]
+fn negative_padding_and_item_margin_shift_positions() {
+    let (mut d, f, k) = setup(LayoutMode::Horizontal);
+    d.get_mut(f).unwrap().layout.as_mut().unwrap().padding = [-10.0, 5.0, 5.0, -4.0];
+    d.get_mut(k[0]).unwrap().child_layout.margin = [0.0, 0.0, 0.0, -3.0];
+    layout_frame(&mut d, f, &NoMeasure);
+    assert_eq!(pos(&d, k[0]), (-7.0, -10.0, 50.0, 20.0));
+    assert_eq!(pos(&d, k[1]), (53.0, -10.0, 30.0, 40.0));
+}
+
+#[test]
+fn hug_includes_children_margins() {
+    let (mut d, f, k) = setup(LayoutMode::Horizontal);
+    d.get_mut(f).unwrap().child_layout.sizing_h = Sizing::Hug;
+    d.get_mut(f).unwrap().child_layout.sizing_v = Sizing::Hug;
+    d.get_mut(k[0]).unwrap().child_layout.margin = [2.0, 4.0, 6.0, 8.0];
+    layout_frame(&mut d, f, &NoMeasure);
+    let frame = d.node(f).unwrap();
+    assert_eq!((frame.width, frame.height), (112.0, 50.0));
+}
+
+#[test]
+fn fill_shares_remaining_width_after_margins() {
+    let (mut d, f, k) = setup(LayoutMode::Horizontal);
+    for id in &k {
+        d.get_mut(*id).unwrap().child_layout.sizing_h = Sizing::Fill;
+    }
+    d.get_mut(k[0]).unwrap().child_layout.margin = [0.0, 10.0, 0.0, 10.0];
+    layout_frame(&mut d, f, &NoMeasure);
+    assert_eq!(pos(&d, k[0]), (15.0, 5.0, 130.0, 20.0));
+    assert_eq!(pos(&d, k[1]), (165.0, 5.0, 130.0, 40.0));
+}
+
+#[test]
+fn individual_cross_alignment_overrides_frame() {
+    use figcraft_doc::ItemAlign;
+    let (mut d, f, k) = setup(LayoutMode::Vertical);
+    d.get_mut(k[0]).unwrap().child_layout.align_self = Some(ItemAlign::Center);
+    d.get_mut(k[0]).unwrap().child_layout.margin = [10.0, 0.0, 5.0, -8.0];
+    layout_frame(&mut d, f, &NoMeasure);
+    assert_eq!(pos(&d, k[0]), (121.0, 15.0, 50.0, 20.0));
+    assert_eq!(pos(&d, k[1]), (5.0, 50.0, 30.0, 40.0));
+}
+
+#[test]
+fn signed_margins_and_item_alignment_roundtrip_through_json() {
+    use figcraft_doc::{ChildLayout, ItemAlign};
+    let original = ChildLayout { margin: [-2.0, 4.0, 0.0, -6.0], align_self: Some(ItemAlign::Stretch), ..ChildLayout::default() };
+    let json = serde_json::to_string(&original).unwrap();
+    let decoded: ChildLayout = serde_json::from_str(&json).unwrap();
+    assert_eq!(decoded, original);
+    let legacy: ChildLayout = serde_json::from_str(r#"{"sizing_h":"fixed","sizing_v":"fixed"}"#).unwrap();
+    assert_eq!(legacy.margin, [0.0; 4]);
+    assert_eq!(legacy.align_self, None);
+}
+
+#[test]
+fn grid_cell_respects_margins() {
+    let mut d = Document::new("grid-margins");
+    let p = d.first_page().unwrap();
+    let f = d.create(p, NodeKind::Frame, "Grid", 0.0, 0.0, 100.0, 80.0).unwrap();
+    d.get_mut(f).unwrap().layout = Some(AutoLayout {
+        mode: LayoutMode::Grid,
+        padding: [0.0; 4],
+        gap: 0.0,
+        cross_gap: 0.0,
+        grid_columns: 1,
+        grid_rows: 1,
+        ..AutoLayout::default()
+    });
+    let c = d.create(f, NodeKind::Rectangle, "Cell", 0.0, 0.0, 10.0, 10.0).unwrap();
+    d.get_mut(c).unwrap().child_layout.margin = [5.0, 6.0, 7.0, 8.0];
+    d.get_mut(c).unwrap().child_layout.sizing_h = Sizing::Fill;
+    d.get_mut(c).unwrap().child_layout.sizing_v = Sizing::Fill;
+    layout_frame(&mut d, f, &NoMeasure);
+    assert_eq!(pos(&d, c), (8.0, 5.0, 86.0, 68.0));
+}
+
+#[test]
+fn wrapped_flow_counts_margins_when_breaking_lines() {
+    let (mut d, f, k) = setup(LayoutMode::Horizontal);
+    {
+        let frame = d.get_mut(f).unwrap();
+        frame.width = 90.0;
+        let l = frame.layout.as_mut().unwrap();
+        l.wrap = true;
+        l.cross_gap = 5.0;
+    }
+    d.get_mut(k[0]).unwrap().child_layout.margin = [0.0, 0.0, 0.0, 15.0];
+    layout_frame(&mut d, f, &NoMeasure);
+    assert_eq!(pos(&d, k[0]).0, 20.0);
+    assert_eq!(pos(&d, k[1]).0, 5.0);
+    assert_eq!(pos(&d, k[1]).1, 30.0);
+}
+
+#[test]
+fn stretch_self_fills_cross_axis_except_margins() {
+    use figcraft_doc::ItemAlign;
+    let (mut d, f, k) = setup(LayoutMode::Horizontal);
+    d.get_mut(k[0]).unwrap().child_layout.margin = [2.0, 0.0, 3.0, 0.0];
+    d.get_mut(k[0]).unwrap().child_layout.align_self = Some(ItemAlign::Stretch);
+    layout_frame(&mut d, f, &NoMeasure);
+    assert_eq!(pos(&d, k[0]), (5.0, 7.0, 50.0, 85.0));
+}
diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml
--- a/crates/ui-egui/locales/en.toml
+++ b/crates/ui-egui/locales/en.toml
@@ -8,6 +8,10 @@
 name = "English"
 
 [ui]
+"Baseline" = "Baseline"
+"Align self" = "Align self"
+"Item margins" = "Item margins"
+"Reverse direction" = "Reverse direction"
 "% of all commits" = "% of all commits"
 "(no longer published)" = "(no longer published)"
 "({n} warnings: some entries were skipped)" = { one = "({n} warning: some entries were skipped)", other = "({n} warnings: some entries were skipped)" }
diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml
--- a/crates/ui-egui/locales/es.toml
+++ b/crates/ui-egui/locales/es.toml
@@ -8,6 +8,10 @@ name = "Español"
 status = "draft — needs native review"
 
 [ui]
+"Align self" = "Alineación propia"
+"Item margins" = "Márgenes del elemento"
+"Reverse direction" = "Invertir la dirección"
+"Baseline" = "Línea base"
 "Add a minute" = "Añadir un minuto"
 "Add column" = "Añadir columna"
 "Add measurement" = "Añadir medición"
diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs
--- a/crates/ui-egui/src/icons.rs
+++ b/crates/ui-egui/src/icons.rs
@@ -103,6 +103,7 @@ icons! {
     LayoutV,
     LayoutWrap,
     LayoutGrid,
+    LayoutReverse,
     AutoLayout,
     ClipContent,
     Effects,
diff --git a/crates/ui-egui/src/icons/draw.rs b/crates/ui-egui/src/icons/draw.rs
--- a/crates/ui-egui/src/icons/draw.rs
+++ b/crates/ui-egui/src/icons/draw.rs
@@ -527,6 +527,14 @@ fn draw(p: &P, icon: Icon) {
             p.path(&w, false);
             p.head((3.5, 10.5), PI, 2.8);
         }
+        // A row of blocks with an arrow running the other way: the flow is reversed.
+        LayoutReverse => {
+            p.fill_rect(2.0, 3.0, 5.0, 9.0, 1.0);
+            p.fill_rect(5.5, 3.0, 8.5, 9.0, 1.0);
+            p.line(9.5, 12.5, 9.5, 10.5);
+            p.path(&[(9.5, 12.5), (6.5, 12.5)], false);
+            p.head((6.0, 12.5), PI, 2.6);
+        }
         AutoLayout => {
             p.rect(2.5, 2.5, 13.5, 13.5, 2.0);
             p.fill_rect(5.0, 5.0, 7.0, 11.0, 0.0);
diff --git a/crates/ui-egui/src/panels/layout_panel.rs b/crates/ui-egui/src/panels/layout_panel.rs
--- a/crates/ui-egui/src/panels/layout_panel.rs
+++ b/crates/ui-egui/src/panels/layout_panel.rs
@@ -67,6 +67,11 @@ pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node
     if grid_parent && common(doc, sel, |n| n.child_layout.absolute) == Some(false) {
         ui.add_enabled_ui(!nested, |ui| grid_cell(app, ui, doc, sel)).response.on_disabled_hover_text(from_main);
     }
+    // Signed outer margins and per-child cross alignment are editable native properties.
+    let flow_parent = sel.iter().all(|i| parent_of(doc, *i).and_then(|p| p.layout.as_ref()).is_some());
+    if flow_parent && common(doc, sel, |n| n.child_layout.absolute) == Some(false) {
+        ui.add_enabled_ui(!nested, |ui| item_spacing(app, ui, doc, sel)).response.on_disabled_hover_text(from_main);
+    }
     if frames {
         let clip = common(doc, sel, |n| n.clip);
         row(ui, 32.0, |ui| {
@@ -77,6 +82,52 @@ pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node
     }
 }
 
+/// Edit per-child signed margins and cross-axis alignment, including imported Penpot values.
+fn item_spacing(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
+    let ids = sel.to_vec();
+    let col = widgets::col_width(ui.available_width());
+    widgets::sublabels(ui, tr("Item margins"), None);
+    for (a, b) in [(0usize, 1usize), (2, 3)] {
+        row(ui, 32.0, |ui| {
+            for i in [a, b] {
+                let key = ["marginTop", "marginRight", "marginBottom", "marginLeft"][i];
+                let label = ["Top", "Right", "Bottom", "Left"][i];
+                let val = common(doc, sel, |n| n.child_layout.margin[i]);
+                let e = crate::variable_ui::number_field(app, ui, &ids, key, Id::unique(("item_margin", i)), Lead::Text(tr(label)), val, "", col);
+                app.field(&ids, key, e, |v| json!(v.clamp(-100_000.0, 100_000.0)));
+            }
+        });
+    }
+    // A grid cell has its own horizontal/vertical controls; align-self is a flex property.
+    if sel.iter().any(|i| parent_of(doc, *i).and_then(|p| p.layout.as_ref()).is_some_and(|l| l.mode == LayoutMode::Grid)) {
+        return;
+    }
+    let current = common(doc, sel, |n| n.child_layout.align_self);
+    let label = match current {
+        Some(Some(ItemAlign::Min)) => "Start",
+        Some(Some(ItemAlign::Center)) => "Center",
+        Some(Some(ItemAlign::Max)) => "End",
+        Some(Some(ItemAlign::Stretch)) => "Stretch",
+        Some(Some(ItemAlign::Baseline)) => crate::i18n::tr_noop("Baseline"),
+        Some(None) => "Auto",
+        None => "Mixed",
+    };
+    widgets::sublabels(ui, tr("Align self"), None);
+    row(ui, 32.0, |ui| {
+        let resp = widgets::dropdown(ui, tr(label), col, None);
+        menu_popup(&resp).show(|ui| {
+            for (label, value) in
+                [("Auto", "auto"), ("Start", "min"), ("Center", "center"), ("End", "max"), ("Stretch", "stretch"), ("Baseline", "baseline")]
+            {
+                if menu_item(ui, tr(label), "") {
+                    app.set_props(&ids, json!({"alignSelf": value}));
+                    ui.close();
+                }
+            }
+        });
+    });
+}
+
 fn direction(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) {
     row(ui, 32.0, |ui| {
         let items = [(Icon::LayoutV, tr("Vertical")), (Icon::LayoutH, tr("Horizontal")), (Icon::LayoutGrid, tr("Grid"))];
@@ -96,6 +147,12 @@ fn direction(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) {
             if resp.clicked() {
                 app.set_props(ids, json!({"wrap": !l.wrap}));
             }
+            // Reverse child order within each line, retaining its alignment.
+            let resp = icons::button(ui, Icon::LayoutReverse, 24.0, l.reverse, tr("Reverse direction"));
+            crate::a11y::toggle(&resp, tr("Reverse direction"), l.reverse);
+            if resp.clicked() {
+                app.set_props(ids, json!({"reverse": !l.reverse}));
+            }
         }
     });
 }
@@ -303,6 +360,8 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) {
         let spread = !grid_mode && l.wrap && l.content_align == ContentAlign::SpaceBetween;
         let cur_cross = match l.cross_align {
             _ if spread => 9,
+            // A stretched cross axis uses the whole of it, like the spread lines do.
+            CrossAlign::Stretch => 9,
             CrossAlign::Min | CrossAlign::Baseline => 0,
             CrossAlign::Center => 1,
             CrossAlign::Max => 2,
@@ -428,7 +487,7 @@ fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) {
                 for (key, name, icon, v, k) in pair {
                     let e =
                         crate::variable_ui::number_field(app, ui, ids, key, Id::unique(("padding", *k)), Lead::Icon(*icon, name), Some(*v), "", col);
-                    app.field(ids, key, e, |v| json!(v.max(0.0)));
+                    app.field(ids, key, e, |v| json!(v.clamp(-100_000.0, 100_000.0)));
                 }
                 if r == 0 {
                     toggle(ui);
@@ -448,7 +507,7 @@ fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) {
                 "",
                 col,
             );
-            app.field(ids, "paddingHorizontal", e, |v| json!(v.max(0.0)));
+            app.field(ids, "paddingHorizontal", e, |v| json!(v.clamp(-100_000.0, 100_000.0)));
             let e = crate::variable_ui::number_field(
                 app,
                 ui,
@@ -460,7 +519,7 @@ fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) {
                 "",
                 col,
             );
-            app.field(ids, "paddingVertical", e, |v| json!(v.max(0.0)));
+            app.field(ids, "paddingVertical", e, |v| json!(v.clamp(-100_000.0, 100_000.0)));
             toggle(ui);
         });
     }
diff --git a/docs/auto-layout-parity.md b/docs/auto-layout-parity.md
--- a/docs/auto-layout-parity.md
+++ b/docs/auto-layout-parity.md
@@ -1,11 +1,11 @@
 # Auto layout and constraints parity
 
-> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-17 · **Change:** minor (adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys only to frames, with warnings (#603); core editor leftovers: auto gap handles, wrapped-line alignment; behaviour suite written from the public documentation, every rule's status; fixes and the missing settings it found) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-27 · **Change:** minor (layout-only extraction from #1334; default zero margins omitted, reversed CSS alignment corrected, percentage exports include padding, gaps and inside strokes, translated inspector controls, and stretched rows hug natural heights) · **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%,
 ready ~74% (estimated)**: every rule of the public documentation is a test case with the sizes and
-positions it implies (`crates/layout/src/behaviour_tests.rs`, 64 cases, and
+positions it implies (`crates/layout/src/behaviour_tests.rs`, hand-computed cases, and
 `crates/engine/src/al_behaviour_tests.rs` through commands), and all pass; nothing yet against real
 Figma files. Remaining: 3–6 h.
 
@@ -40,7 +40,8 @@ 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 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 |
+| 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` (19 cases), `al_content_align_tests`; `contentAlign`, the Row/Column gap mode in the Design panel, REST and `.fig` both ways, `align-content` in CSS |
+| The wrapped lines take the free space around (half as much at each edge) or evenly (as much between them as at the edges) | **added** | `content_align_tests` (6 cases), `contentAlign: spaceAround / spaceEvenly`, `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` |
@@ -51,13 +52,28 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver
 | A hugging frame with an auto gap | silent | it hugs with the gap value, then shares the (no) extra space |
 | One layout pass settles every case | pass | every case is laid out twice and must not move |
 
+## Penpot parity (flexbox)
+
+These CSS-compatible layout rules are available through commands and the Design panel,
+independently of file importers. They provide the model needed for future Penpot interoperability.
+
+| Rule | Status | Evidence / notes |
+|---|---|---|
+| A reversed flow (`row-reverse` / `column-reverse`) lays the children out in the opposite order along the main axis; the main-axis alignment does not mirror | **added** | `reverse_direction_rules` (6 cases), `reverse` prop, `reverseDirection` in REST, `flex-direction` in CSS; Penpot's own engine (the `reverse?` flag in `calc-layout-data`) reverses the children, not the justification. The `.fig` format has no field for it: the setting travels as plugin data under our own `figcraft`/`layout` key and our importer reads it back |
+| A stretched cross axis (`align-items: stretch`) gives the children the line's cross size, unless they ask for an alignment of their own | **added** | `stretch_cross_axis_rules` (6 cases, including Hug frames and main-axis Fill children), `CrossAlign::Stretch`, `align-items: stretch` in CSS |
+| A percentage grid track takes its share of the frame's inner size, before the fill tracks share the rest | **added** | `grid_rules`: fixed and Hug axes, padding, gaps and spanning children; commands accept `"50%"` |
+| A wrapped flow's lines take the free space around or evenly | **added** | `content_align_tests` (6 cases), `align-content` in CSS |
+| A `space-between` flow with no free space keeps the lines where the cross alignment says | **fixed** | the lines touched and sat at the start |
+
 ## Features
 
 | Feature | Status | Evidence / notes |
 |---|---|---|
 | Horizontal, vertical, wrap (both directions) | done | `crates/layout` |
-| Grid auto layout: fixed / hug / `fr` tracks, cells, spans, gaps, cell alignment | done | `fr` like CSS `fr` |
+| Reversed flows (row-reverse, column-reverse) | done | `reverse` prop, Design panel control |
+| Grid auto layout: fixed / hug / `fr` / percentage tracks, cells, spans, gaps, cell alignment | done | `fr` like CSS `fr`, `percent` like CSS `percent` |
 | Gap, auto gap (between / evenly / around), padding (per side), alignment 3×3 | done | |
+| Cross-axis stretch and the wrapped lines' space-around / space-evenly | done | `CrossAlign::Stretch`, `ContentAlign::SpaceAround / SpaceEvenly` |
 | Text baseline alignment | done | |
 | Hug / Fill / Fixed sizing with coherent rules (Fill turns a hugging parent Fixed) | done | `al_nesting_tests.rs`, `al_behaviour_tests.rs` |
 | Min/max width and height, redistribution | done | hugging text wraps at its max |
@@ -77,6 +93,9 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-27 | minor | M2: isolate layout extensions from #1334; omit zero margins, correct reversed CSS and percentage-track exports, translate inspector controls, and cover stretched rows hugging fill-main children. Numbers unchanged. |
+| 2026-10-25 | patch | PR #1143 follow-up (M2): definite percentage tracks contribute their resolved width/height when a spanning child grows hug tracks. Both axes have regression cases with padding and gaps. Grid commands parse percentage strings and reject non-finite shares, with undo coverage. Numbers unchanged. |
+| 2026-10-21 | minor | Auto layout, Penpot parity (M2): a reversed flow (`row-reverse` / `column-reverse`) lays the children out in the opposite order along the main axis, the way Penpot's own engine does (it reverses the children, not the justification); a stretched cross axis gives the children the line's cross size unless they ask for an alignment of their own; a wrapped flow's lines take the free space around or evenly; a percentage grid track takes its share of the frame's inner size. New `reverse` prop and Design panel control, `CrossAlign::Stretch`, `ContentAlign::SpaceAround / SpaceEvenly`, `GridTrack::percent`. 19 new hand-computed layout cases. The `.fig` format has no field for a reversed flow, a stretched cross axis, the wrapped lines' space-around/space-evenly or the grid tracks' sizes: they travel as plugin data under our own `figcraft`/`layout` key, so a `.fig` round trip keeps them, and an instance carries a reversed flow as an override like the other alignment settings. Numbers unchanged. |
 | 2026-10-17 | minor | Community batch #753: adding auto layout to an empty frame keeps its size, Fixed, while a frame with content hugs it (#369); `node.set` applies auto layout keys to the frames among its targets and warns about the others, and a font style the family lacks warns with the nearest face (#603). Checked over MCP. Numbers unchanged |
 | 2026-10-13 | minor | Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged. |
 | 2026-10-13 | minor | Auto layout, wrapped-line alignment (M2): a wrapping layout's lines (rows of a horizontal one, columns of a vertical one) are packed where the cross alignment says (start, centre, end; before, they always sat at the start), stretch when every child fills across the flow, or take the free space between them (`contentAlign: spaceBetween`, the Design panel's Row/Column gap mode, the alignment pad lighting the whole cross axis). Read from REST JSON and `.fig`, written to `.fig`, `align-content` in CSS, an instance override with the other alignment settings. 13 hand-computed layout cases and command tests. Numbers unchanged. |
