diff --git a/CLAUDE.md b/CLAUDE.md
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -62,6 +62,7 @@ code.
 - No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!` in shipped code
   (workspace clippy lints deny them; tests may use them). Return `Result`/`Option`; use `.get()` for
   data-derived indexes; slice strings only at char boundaries; check floats are finite; cap sizes.
+- New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes before modules or other crate items; crate-level `//!` documentation may precede them.
 - `figcraft_engine::guard` turns a panicking command into `EngineError::Internal` and the document
   is rolled back; the desktop app also guards each UI frame and writes recovery copies. These are
   safety nets, not substitutes for `Result`.
diff --git a/Cargo.toml b/Cargo.toml
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -102,6 +102,9 @@ webbrowser = "1"
 unsafe_code = "forbid"
 
 [workspace.lints.clippy]
+cognitive_complexity = "deny"
+too_many_arguments = "deny"
+wildcard_imports = "deny"
 dbg_macro = "warn"
 # Style lints from newer toolchains that fight index-heavy pixel code.
 needless_range_loop = "allow"
diff --git a/clippy.toml b/clippy.toml
--- a/clippy.toml
+++ b/clippy.toml
@@ -7,3 +7,4 @@ too-many-arguments-threshold = 10
 disallowed-types = [
     { path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
 ]
+cognitive-complexity-threshold = 30
diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs
--- a/crates/doc/src/node.rs
+++ b/crates/doc/src/node.rs
@@ -1,7 +1,10 @@
 //! Nodes: one struct with the properties every node can have, and a kind with per-type data.
 
 use crate::NodeId;
-use crate::props::*;
+use crate::props::{
+    AutoLayout, ChildLayout, Constraints, Corners, Effect, EffectKind, ExportSetting, LayoutGrid, SPREAD_MITER_LIMIT, StrokeAlign, StrokeCap,
+    StrokeProps,
+};
 use crate::proto::{Interaction, Overflow, ScrollPosition};
 use crate::styles::{CollectionId, ModeId, StyleRefs, VariableId};
 use crate::text::TextData;
diff --git a/crates/format/src/fig/assets_tests.rs b/crates/format/src/fig/assets_tests.rs
--- a/crates/format/src/fig/assets_tests.rs
+++ b/crates/format/src/fig/assets_tests.rs
@@ -274,19 +274,30 @@ fn variables_modes_and_bindings_round_trip() {
 #[test]
 fn variables_are_written_as_nodes_on_a_hidden_page() {
     let nodes = written(&themed());
-    let hidden = named(&nodes, "Internal Only Canvas");
+    assert_hidden_theme(&nodes);
+    assert_variable_values(&nodes);
+    assert_variable_bindings(&nodes);
+}
+
+fn assert_hidden_theme(nodes: &[Value]) {
+    let hidden = named(nodes, "Internal Only Canvas");
     assert_eq!((hidden["type"].as_str(), hidden["internalOnly"].as_bool(), hidden["visible"].as_bool()), (Some("CANVAS"), Some(true), Some(false)));
-    let set = named(&nodes, "Theme");
+    let set = named(nodes, "Theme");
     assert_eq!(set["type"], "VARIABLE_SET");
     assert_eq!(set["parentIndex"]["guid"], hidden["guid"]);
     let modes = set["variableSetModes"].as_array().unwrap();
     assert_eq!(modes.iter().map(|m| m["name"].as_str().unwrap()).collect::<Vec<_>>(), ["Light", "Dark"]);
-    let bg = named(&nodes, "color/bg");
+}
+
+fn assert_variable_values(nodes: &[Value]) {
+    let set = named(nodes, "Theme");
+    let modes = set["variableSetModes"].as_array().unwrap();
+    let bg = named(nodes, "color/bg");
     assert_eq!((bg["type"].as_str(), bg["variableResolvedType"].as_str()), (Some("VARIABLE"), Some("COLOR")));
     assert_eq!(bg["variableSetID"]["guid"], set["guid"]);
     assert_eq!(bg["description"], "Behind everything");
     assert_eq!(bg["variableScopes"], json!(["FRAME_FILL", "SHAPE_FILL"]));
-    assert_eq!(named(&nodes, "color/fg")["variableScopes"], json!(["TEXT_FILL", "STROKE", "EFFECT_COLOR"]));
+    assert_eq!(named(nodes, "color/fg")["variableScopes"], json!(["TEXT_FILL", "STROKE", "EFFECT_COLOR"]));
     let values = bg["variableDataValues"]["entries"].as_array().unwrap();
     assert_eq!(values.len(), 2);
     assert_eq!(values[1]["modeID"], modes[1]["id"]);
@@ -295,19 +306,25 @@ fn variables_are_written_as_nodes_on_a_hidden_page() {
         (Some("COLOR"), Some(0.25))
     );
     // An alias names the variable's node; every value says the type it resolves to.
-    let accent = &named(&nodes, "color/accent")["variableDataValues"]["entries"];
+    let accent = &named(nodes, "color/accent")["variableDataValues"]["entries"];
     assert_eq!(accent[0]["variableData"]["dataType"], "ALIAS");
     assert_eq!(accent[0]["variableData"]["resolvedDataType"], "COLOR");
-    assert_eq!(accent[0]["variableData"]["value"]["alias"]["guid"], named(&nodes, "color/fg")["guid"]);
-    assert_eq!(named(&nodes, "title")["variableDataValues"]["entries"][1]["variableData"]["value"]["textValue"], "Grüße 😀");
-    assert_eq!(named(&nodes, "shown")["variableDataValues"]["entries"][1]["variableData"]["value"]["boolValue"], false);
-    assert_eq!(named(&nodes, "space/gap")["variableDataValues"]["entries"][0]["variableData"]["value"]["floatValue"], 8.0);
+    assert_eq!(accent[0]["variableData"]["value"]["alias"]["guid"], named(nodes, "color/fg")["guid"]);
+    assert_eq!(named(nodes, "title")["variableDataValues"]["entries"][1]["variableData"]["value"]["textValue"], "Grüße 😀");
+    assert_eq!(named(nodes, "shown")["variableDataValues"]["entries"][1]["variableData"]["value"]["boolValue"], false);
+    assert_eq!(named(nodes, "space/gap")["variableDataValues"]["entries"][0]["variableData"]["value"]["floatValue"], 8.0);
+}
+
+fn assert_variable_bindings(nodes: &[Value]) {
+    let bg = named(nodes, "color/bg");
+    let set = named(nodes, "Theme");
+    let modes = set["variableSetModes"].as_array().unwrap();
     // Bindings: on the paint, the gradient stop, the effect and the grid, and in the layer's map.
-    let card = named(&nodes, "Card");
+    let card = named(nodes, "Card");
     assert_eq!(card["fillPaints"][0]["colorVar"]["value"]["alias"]["guid"], bg["guid"]);
     assert_eq!(card["variableModeBySetMap"]["entries"][0]["variableSetID"]["guid"], set["guid"]);
     assert_eq!(card["variableModeBySetMap"]["entries"][0]["variableModeID"], modes[1]["id"]);
-    assert_eq!(card["layoutGrids"][0]["gutterSizeVar"]["value"]["alias"]["guid"], named(&nodes, "space/gap")["guid"]);
+    assert_eq!(card["layoutGrids"][0]["gutterSizeVar"]["value"]["alias"]["guid"], named(nodes, "space/gap")["guid"]);
     let fields: Vec<&str> =
         card["variableConsumptionMap"]["entries"].as_array().unwrap().iter().map(|e| e["variableField"].as_str().unwrap()).collect();
     for f in
@@ -315,7 +332,7 @@ fn variables_are_written_as_nodes_on_a_hidden_page() {
     {
         assert!(fields.contains(&f), "{f} in {fields:?}");
     }
-    let swatch = named(&nodes, "Swatch");
+    let swatch = named(nodes, "Swatch");
     assert!(swatch["fillPaints"][1]["stopsVar"][0].get("colorVar").is_none());
     assert_eq!(swatch["fillPaints"][1]["stopsVar"][1]["colorVar"]["value"]["alias"]["guid"], bg["guid"]);
     assert_eq!(swatch["fillPaints"][1]["stopsVar"].as_array().unwrap().len(), 2);
@@ -324,7 +341,7 @@ fn variables_are_written_as_nodes_on_a_hidden_page() {
         assert_eq!(swatch["effects"][0][key]["dataType"], "ALIAS", "{key}");
     }
     // The page sets a mode too.
-    assert_eq!(named(&nodes, "Page 1")["variableModeBySetMap"]["entries"][0]["variableSetID"]["guid"], named(&nodes, "Spacing")["guid"]);
+    assert_eq!(named(nodes, "Page 1")["variableModeBySetMap"]["entries"][0]["variableSetID"]["guid"], named(nodes, "Spacing")["guid"]);
 }
 
 #[test]
diff --git a/crates/format/src/fig/export/assets.rs b/crates/format/src/fig/export/assets.rs
--- a/crates/format/src/fig/export/assets.rs
+++ b/crates/format/src/fig/export/assets.rs
@@ -1,5 +1,8 @@
 use super::common::{layout_grid, node_field, prop_type, resolved_type, scope_name, variant_pairs};
-use super::*;
+use super::{
+    AUX, Action, Affine, Bound, Color, Exporter, Fields, Kv, LAYERS, MAX_NODES, Node, NodeId, NodeKind, Overlay, OverlayPosition, Paint, StyleKind,
+    TextData, VariableValue, arr, b, color, e, fl, guid, matrix, o, position, root_fields, s, u, xy,
+};
 
 impl<'d> Exporter<'d> {
     /// GUIDs for every layer (pages first, then depth-first), component property definitions,
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,4 +1,8 @@
-use super::*;
+use super::{
+    Bound, ExportSize, Fields, GAMEPAD_BUTTONS, GridAlign, GridKind, KeyMods, Kv, LetterSpacing, LineHeight, MAX_NETWORK, Node, NodeKind, Paint,
+    PropDef, SCHEMA, TextCase, TextDecoration, TextStyle, VariableType, VectorNetwork, WindingRule, arr, b, color, e, face_style, fl, guid, int, o,
+    s, xy,
+};
 
 /// Longest kept description of such a layer (bytes of JSON).
 const MAX_STASH: usize = 1 << 20;
diff --git a/crates/format/src/fig/export/component.rs b/crates/format/src/fig/export/component.rs
--- a/crates/format/src/fig/export/component.rs
+++ b/crates/format/src/fig/export/component.rs
@@ -1,7 +1,7 @@
 use super::common::{
     bool_data, changed, prop_type, resolved_alike, scaled, sort_position, symbol_data, text_data, text_value, var_data, variant_pairs,
 };
-use super::*;
+use super::{AUX, BTreeMap, Exporter, Fields, Kv, MAX_NODES, Node, NodeId, NodeKind, PropDef, Value, arr, b, e, fl, guid, o, s};
 
 impl<'d> Exporter<'d> {
     /// A component property definition of `owner` (a component, or a set), `index`-th in its list.
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
@@ -1,5 +1,9 @@
 use super::common::{export_setting, layout_grid, outlined, prop_type, variant_pairs};
-use super::*;
+use super::{
+    AUX, AutoLayout, BoolOp, Bound, ContentAlign, CrossAlign, Exporter, Fields, HConstraint, Kv, LAYERS, LayoutMode, MAX_DEPTH, MainAlign, MaskType,
+    Node, NodeId, NodeKind, PropDef, STASH, Sizing, TextData, TextStyle, VConstraint, WindingRule, arr, b, blend_name, color, e, fl, grid_sizing,
+    guid, int, matrix, o, position, root_fields, s, u, xy,
+};
 
 impl<'d> Exporter<'d> {
     /// Write layer `id` (child `index` of the layer with GUID `parent_guid`) and its subtree.
diff --git a/crates/format/src/fig/export/paint.rs b/crates/format/src/fig/export/paint.rs
--- a/crates/format/src/fig/export/paint.rs
+++ b/crates/format/src/fig/export/paint.rs
@@ -1,5 +1,8 @@
 use super::common::one_sided;
-use super::*;
+use super::{
+    Affine, Bound, EffectKind, Exporter, Fields, GradientKind, ImageScaleMode, Kv, MAX_IMAGE, Node, NodeKind, NoiseMode, Paint, PaintKind,
+    StrokeAlign, StrokeCap, arr, b, blend_name, cap_name, color, e, fl, join_name, matrix, o, s, sha1_hex, xy,
+};
 
 impl<'d> Exporter<'d> {
     /// A paint list: a layer's fills (`fills`) or strokes with the variables `bound` to them.
diff --git a/crates/format/src/fig/export/prototype.rs b/crates/format/src/fig/export/prototype.rs
--- a/crates/format/src/fig/export/prototype.rs
+++ b/crates/format/src/fig/export/prototype.rs
@@ -1,5 +1,8 @@
 use super::common::key_codes;
-use super::*;
+use super::{
+    Action, Direction, Easing, Exporter, Fields, Interaction, Kv, Node, NodeId, Overflow, OverlayPosition, ScrollPosition, Transition, Trigger, arr,
+    b, color, e, fl, int, o, position, s,
+};
 
 impl<'d> Exporter<'d> {
     /// Interactions, scrolling, flow starts and the overlay settings this layer is opened with.
diff --git a/crates/format/src/fig/export/shape.rs b/crates/format/src/fig/export/shape.rs
--- a/crates/format/src/fig/export/shape.rs
+++ b/crates/format/src/fig/export/shape.rs
@@ -1,5 +1,9 @@
 use super::common::{style_fields, unsupported_decoration_look};
-use super::*;
+use super::{
+    Affine, BezPath, Bound, Exporter, Fields, Kv, ListStyle, MAX_CANVAS, MAX_CHARS, MAX_NETWORK, Node, NodeKind, Paint, StrokeAlign, StrokeCap,
+    StrokeJoin, TextAlignH, TextAlignV, TextAutoResize, TextData, TextStyle, VectorNetwork, WindingRule, arr, b, cap_name, e, finite, fl, int,
+    join_name, network, o, path_blob, s, u, xy,
+};
 
 impl<'d> Exporter<'d> {
     /// Text: its style, box settings, and characters with their styled runs. `fills`: the layer's
diff --git a/crates/format/src/fig/kiwi.rs b/crates/format/src/fig/kiwi.rs
--- a/crates/format/src/fig/kiwi.rs
+++ b/crates/format/src/fig/kiwi.rs
@@ -400,7 +400,7 @@ impl<'s> Decoder<'s, '_> {
 /// An encoder for the same format: the `.fig` export writes its schema and message with it (and
 /// test fixtures are built with it). Values are checked against the schema; it never panics.
 pub mod encode {
-    use super::*;
+    use super::{BUILTINS, Field, Kind, KiwiError, Kv, MAX_DEPTH, Schema, Type, err};
 
     #[derive(Default)]
     pub struct Writer(pub Vec<u8>);
diff --git a/crates/format/src/fig/map/component.rs b/crates/format/src/fig/map/component.rs
--- a/crates/format/src/fig/map/component.rs
+++ b/crates/format/src/fig/map/component.rs
@@ -1,6 +1,6 @@
 use super::layer::own_text;
 use super::text::char_styles;
-use super::*;
+use super::{BTreeMap, BTreeSet, Kv, MAX_DEPTH, Mapper, Obj, Rc, Value, View, guid, json};
 
 /// An override row names a layer of the main by its current GUID or by the `overrideKey` the
 /// layer had when the row was written: the key becomes the current GUID, unless the layer is
diff --git a/crates/format/src/fig/map/connector.rs b/crates/format/src/fig/map/connector.rs
--- a/crates/format/src/fig/map/connector.rs
+++ b/crates/format/src/fig/map/connector.rs
@@ -1,5 +1,5 @@
 use super::layer::{cap_name, own_text, svg_path, valid_size};
-use super::*;
+use super::{Affine, Kv, Mapper, NO_STYLE, Obj, Point, Value, View, guid, json, matrix, network, num, rows};
 
 impl<'a> Mapper<'a> {
     /// A connector's end caps, and when it has no saved outline or network a straight line
diff --git a/crates/format/src/fig/map/document.rs b/crates/format/src/fig/map/document.rs
--- a/crates/format/src/fig/map/document.rs
+++ b/crates/format/src/fig/map/document.rs
@@ -1,7 +1,7 @@
 use super::component::{assigned, prop_guid};
 use super::paint::color;
 use super::variables::variable_value;
-use super::*;
+use super::{Affine, BTreeMap, BTreeSet, Kv, MAX_DEPTH, Mapper, Obj, Value, View, copy_str, guid, json, rows};
 
 impl<'a> Mapper<'a> {
     /// The DOCUMENT with its pages.
diff --git a/crates/format/src/fig/map/layer.rs b/crates/format/src/fig/map/layer.rs
--- a/crates/format/src/fig/map/layer.rs
+++ b/crates/format/src/fig/map/layer.rs
@@ -2,7 +2,10 @@ use super::component::{Entry, Inst, OverrideRow, prop_ref};
 use super::connector::connector_has_saved_label;
 use super::layout::{layout_grid, tracks};
 use super::paint::{effect, export_setting, paint, paint_bound, stroke};
-use super::*;
+use super::{
+    Affine, BTreeMap, BTreeSet, Kv, MAX_COMMANDS, MAX_DEPTH, Mapper, Obj, Value, View, copy_num, copy_str, guid, json, matrix, network, num, rows,
+    size,
+};
 
 /// How the layers of a resized container follow it when the file gives no derived layout for
 /// them: a frame's by their constraints, from its main's size to its own; a group's scale with it.
@@ -61,6 +64,22 @@ impl Fit {
     }
 }
 
+struct LayerChildren<'s, 'a> {
+    id: &'s str,
+    inst: Option<&'s Inst<'a>>,
+    base: &'s Kv<'a>,
+    child_space: Affine,
+    view: &'s View<'a>,
+    object: &'s mut Obj,
+    ty: &'s str,
+    rest_id: &'s str,
+    connector_snapshot: bool,
+    connector_terminal: bool,
+    depth: usize,
+    included: Option<&'s BTreeSet<String>>,
+    slot_content: Option<String>,
+}
+
 impl<'a> Mapper<'a> {
     /// Layer `id` (inside instance `inst` when expanding one) and its subtree. `space`: from the
     /// parent's coordinates to the enclosing frame's (groups and booleans place their children in
@@ -136,16 +155,7 @@ impl<'a> Mapper<'a> {
             connector_snapshot && (!connector_has_saved_label(&view) || depth >= MAX_DEPTH || self.count.saturating_add(1) >= self.budget());
         // A supported connector snapshot has its own note below instead of the generic stand-in one.
         let stand = stand_in(own).filter(|_| !connector_snapshot);
-        let ty = match own {
-            "ROUNDED_RECTANGLE" => "RECTANGLE",
-            "SYMBOL" => "COMPONENT",
-            "FRAME" if view.flag("isStateGroup") == Some(true) => "COMPONENT_SET",
-            "FRAME" if view.flag("resizeToFit") == Some(true) => "GROUP",
-            "VARIABLE" | "VARIABLE_SET" => return None,
-            "CONNECTOR" if connector_snapshot && connector_terminal => "VECTOR",
-            "CONNECTOR" if connector_snapshot => "FRAME",
-            t => stand.map_or(t, |s| s.0),
-        };
+        let ty = Self::mapped_type(own, &view, connector_snapshot, connector_terminal, stand)?;
         if let Some((_, what)) = stand.filter(|_| own != "TEXT_PATH") {
             self.warn(format!("{} layers import as {what}", own.to_ascii_lowercase().replace('_', " ")));
         }
@@ -225,18 +235,59 @@ impl<'a> Mapper<'a> {
             source.insert("slotOverride".into(), json!(true));
         }
         let child_space = if matches!(ty, "GROUP" | "BOOLEAN_OPERATION") { space * view.transform().unwrap_or_default() } else { Affine::IDENTITY };
-        let mut kids = if connector_snapshot && !connector_terminal { self.connector_snapshot_children(&view, &rest_id) } else { Vec::new() };
+        let mut kids = self.map_layer_children(LayerChildren {
+            id,
+            inst,
+            base,
+            child_space,
+            view: &view,
+            object: &mut o,
+            ty,
+            rest_id: &rest_id,
+            connector_snapshot,
+            connector_terminal,
+            depth,
+            included,
+            slot_content,
+        });
+        if stand.is_some_and(|s| s.0 == "FRAME") {
+            kids.splice(0..0, self.shape_with_text(&mut o, &view, own, &rest_id));
+            kids.extend(self.inner_text(&o, &view, &rest_id, own == "STICKY"));
+        }
+        if !kids.is_empty() {
+            o.insert("children".into(), Value::Array(kids));
+        }
+        Some(Value::Object(o))
+    }
+
+    fn map_layer_children(&mut self, context: LayerChildren<'_, 'a>) -> Vec<Value> {
+        let LayerChildren {
+            id,
+            inst,
+            base,
+            child_space,
+            view,
+            object: o,
+            ty,
+            rest_id,
+            connector_snapshot,
+            connector_terminal,
+            depth,
+            included,
+            slot_content,
+        } = context;
+        let mut kids = if connector_snapshot && !connector_terminal { self.connector_snapshot_children(view, rest_id) } else { Vec::new() };
         if connector_terminal {
             // The saved vector is flattened into the connector node when no child slot remains.
         } else if ty == "INSTANCE" {
-            if let Some((sub, sym)) = self.instance(&mut o, &view, id, inst, &rest_id) {
+            if let Some((sub, sym)) = self.instance(o, view, id, inst, rest_id) {
                 let main = self.nodes.get(&sym).map(|m| View::of(m));
                 let fit = Fit::frame(main.as_ref().and_then(View::size), view.size());
                 // An instance that records no auto layout of its own has its main's.
                 let view = match main {
                     Some(m) if view.get("stackMode").is_none() && m.get("stackMode").is_some() => {
                         let layered = View { fitted: view.fitted, ..m.with(view.layers.iter().copied()) };
-                        self.auto_layout(&mut o, &layered);
+                        self.auto_layout(o, &layered);
                         layered
                     }
                     _ => view.clone(),
@@ -252,7 +303,7 @@ impl<'a> Mapper<'a> {
                 content_inst.slot_content = true;
             }
             for c in self.children(&content) {
-                kids.extend(self.layer_filtered(&c, content_inst.as_ref(), child_space, Some(&view), None, depth + 1, included));
+                kids.extend(self.layer_filtered(&c, content_inst.as_ref(), child_space, Some(view), None, depth + 1, included));
             }
         } else {
             // Inside an instance a layer's children are its main's: they follow it from the main's
@@ -263,17 +314,29 @@ impl<'a> Mapper<'a> {
                 (Some(_), _) => Fit::frame(View::of(base).size(), view.size()),
             };
             for c in self.children(id) {
-                kids.extend(self.layer_filtered(&c, inst, child_space, Some(&view), fit, depth + 1, included));
+                kids.extend(self.layer_filtered(&c, inst, child_space, Some(view), fit, depth + 1, included));
             }
         }
-        if stand.is_some_and(|s| s.0 == "FRAME") {
-            kids.splice(0..0, self.shape_with_text(&mut o, &view, own, &rest_id));
-            kids.extend(self.inner_text(&o, &view, &rest_id, own == "STICKY"));
-        }
-        if !kids.is_empty() {
-            o.insert("children".into(), Value::Array(kids));
+        kids
+    }
+
+    fn mapped_type<'s>(
+        own: &'s str,
+        view: &View<'a>,
+        connector_snapshot: bool,
+        connector_terminal: bool,
+        stand: Option<(&'static str, &'static str)>,
+    ) -> Option<&'s str> {
+        match own {
+            "ROUNDED_RECTANGLE" => Some("RECTANGLE"),
+            "SYMBOL" => Some("COMPONENT"),
+            "FRAME" if view.flag("isStateGroup") == Some(true) => Some("COMPONENT_SET"),
+            "FRAME" if view.flag("resizeToFit") == Some(true) => Some("GROUP"),
+            "VARIABLE" | "VARIABLE_SET" => None,
+            "CONNECTOR" if connector_snapshot && connector_terminal => Some("VECTOR"),
+            "CONNECTOR" if connector_snapshot => Some("FRAME"),
+            t => Some(stand.map_or(t, |s| s.0)),
         }
-        Some(Value::Object(o))
     }
 
     /// A non-rectangular shape-with-text's shape, as a vector under its frame (which gives up its
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
@@ -1,5 +1,5 @@
 use super::paint::{bound, color};
-use super::*;
+use super::{BTreeMap, BTreeSet, Kv, MAX_GRID_TRACK_VALUE, MAX_GRID_TRACKS, Mapper, Obj, Value, View, copy_num, guid, json, num};
 
 impl<'a> Mapper<'a> {
     pub(super) fn auto_layout(&mut self, o: &mut Obj, v: &View<'a>) {
diff --git a/crates/format/src/fig/map/paint.rs b/crates/format/src/fig/map/paint.rs
--- a/crates/format/src/fig/map/paint.rs
+++ b/crates/format/src/fig/map/paint.rs
@@ -1,6 +1,6 @@
 use super::layer::cap_name;
 use super::variables::alias;
-use super::*;
+use super::{Affine, Kv, Mapper, NO_STYLE, Obj, Point, Value, View, copy_num, copy_str, guid, json, matrix, num, rows, vec2};
 
 fn filter_number(value: &Kv) -> Option<f64> {
     let number = match value {
diff --git a/crates/format/src/fig/map/proto.rs b/crates/format/src/fig/map/proto.rs
--- a/crates/format/src/fig/map/proto.rs
+++ b/crates/format/src/fig/map/proto.rs
@@ -1,6 +1,6 @@
 use super::paint::color;
 use super::variables::variable_data;
-use super::*;
+use super::{Kv, MAX_ACTION_DEPTH, Mapper, Obj, Value, View, copy_str, guid, json, num, ref_guid};
 
 impl<'a> Mapper<'a> {
     pub(super) fn prototype(&mut self, o: &mut Obj, v: &View<'a>) {
diff --git a/crates/format/src/fig/map/text.rs b/crates/format/src/fig/map/text.rs
--- a/crates/format/src/fig/map/text.rs
+++ b/crates/format/src/fig/map/text.rs
@@ -1,5 +1,5 @@
 use super::paint::{paint, style_link};
-use super::*;
+use super::{Kv, Mapper, Obj, Value, View, copy_num, copy_str, json, network, num};
 
 /// The value from the last override layer holding a valid one for an optional text setting; invalid instance rows fall through.
 fn text_num(v: &View, key: &str) -> Option<f64> {
diff --git a/crates/format/src/fig/map/variables.rs b/crates/format/src/fig/map/variables.rs
--- a/crates/format/src/fig/map/variables.rs
+++ b/crates/format/src/fig/map/variables.rs
@@ -1,5 +1,5 @@
 use super::paint::color;
-use super::*;
+use super::{Kv, MAX_EXPR_DEPTH, Mapper, Obj, Value, View, guid, json, num, ref_guid};
 
 impl<'a> Mapper<'a> {
     pub(super) fn variable_bindings(&mut self, o: &mut Obj, v: &View<'a>) {
diff --git a/crates/format/src/fig/source_identity_tests.rs b/crates/format/src/fig/source_identity_tests.rs
--- a/crates/format/src/fig/source_identity_tests.rs
+++ b/crates/format/src/fig/source_identity_tests.rs
@@ -983,6 +983,11 @@ fn assert_external_namespace(doc: &Document) {
     let copied_frame = child(doc, copied_body, 0);
     assert_eq!(doc.node(frame).unwrap().source, None, "materialized ordinary layers are owned by the frame");
     assert_eq!(doc.node(copied_frame).unwrap().source, Some(frame));
+    assert_external_slots(doc, frame, copied_frame, main);
+    assert_external_copy_state(doc, frame, copied_frame);
+}
+
+fn assert_external_slots(doc: &Document, frame: NodeId, copied_frame: NodeId, main: NodeId) {
     for index in 0..2 {
         let slot = child(doc, frame, index);
         let copied_slot = child(doc, copied_frame, index);
@@ -999,6 +1004,9 @@ fn assert_external_namespace(doc: &Document) {
         assert_eq!(doc.node(child(doc, current, 0)).unwrap().source, Some(child(doc, inherited, 0)));
         assert_eq!(doc.node(child(doc, current, 0)).unwrap().transform.translation().x, 4.0);
     }
+}
+
+fn assert_external_copy_state(doc: &Document, frame: NodeId, copied_frame: NodeId) {
     let old = doc.node(child(doc, frame, 0)).unwrap();
     let current = doc.node(child(doc, copied_frame, 0)).unwrap();
     assert!(!old.visible && old.is_mask && old.opacity < 1.0);
diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs
--- a/crates/format/src/fig/tests.rs
+++ b/crates/format/src/fig/tests.rs
@@ -630,63 +630,82 @@ fn imports_the_fixture() {
     assert!(is_fig(&bytes));
     let (doc, warnings) = import(&bytes, "fallback").unwrap();
     assert_eq!(doc.name, "Fixture");
+    assert_fixture_layers(&doc);
+    assert_fixture_styles(&doc);
+    assert_fixture_variables(&doc);
+    assert_fixture_components(&doc);
+    assert_fixture_prototype(&doc, &warnings);
+}
+
+fn assert_fixture_layers(doc: &Document) {
     // The internal page is skipped.
     assert_eq!(doc.pages.len(), 1);
     let page = doc.node(doc.pages[0]).unwrap();
     assert_eq!(page.name, "Page A");
     // Siblings by position: the frame comes first even though it's listed after the component.
     assert_eq!(doc.node(page.children[0]).unwrap().name, "Screen");
-    let screen = named(&doc, "Screen");
+    let screen = named(doc, "Screen");
     let names: Vec<&str> = screen.children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect();
     assert_eq!(names, ["Photo", "Title", "Group", "Styled", "Card", "Button copy", "Note"]);
     // Image fill from the archive.
-    let photo = named(&doc, "Photo");
+    let photo = named(doc, "Photo");
     let figcraft_doc::color::PaintKind::Image(img) = &photo.fills[0].kind else { panic!("not an image fill") };
     assert_eq!(doc.images.get(&img.image).map(|i| (i.width, i.height)), Some((2, 1)));
     assert_eq!((photo.corners.radii[0], photo.stroke.weight), (6.0, 2.0));
     // Text with a bold run over "the".
-    let NodeKind::Text(t) = &named(&doc, "Title").kind else { panic!("not text") };
+    let NodeKind::Text(t) = &named(doc, "Title").kind else { panic!("not text") };
     assert_eq!(t.characters, "Hi there");
     assert_eq!(t.style.font_size, 16.0);
     assert_eq!(t.leading_trim, figcraft_doc::LeadingTrim::CapHeight, "vertical trim (#466)");
     assert_eq!(t.runs.len(), 1);
     assert_eq!((t.runs[0].start, t.runs[0].end, t.runs[0].style.font_style.as_str()), (3, 6, "Bold"));
     // The group's children keep their place on the page (group at 100, 100).
-    assert!(matches!(named(&doc, "Group").kind, NodeKind::Group));
-    let tri = named(&doc, "Tri");
+    assert!(matches!(named(doc, "Group").kind, NodeKind::Group));
+    let tri = named(doc, "Tri");
     assert!(matches!(&tri.kind, NodeKind::Vector { network } if !network.regions.is_empty()));
     let abs = doc.world_transform(tri.id).translation();
     assert!((abs.x - 105.0).abs() < 1e-6 && (abs.y - 106.0).abs() < 1e-6, "{abs:?}");
-    assert!(matches!(named(&doc, "Star").kind, NodeKind::Star { count: 6, .. }));
-    // The linked style supplies the paint.
-    let styled = named(&doc, "Styled");
+    assert!(matches!(named(doc, "Star").kind, NodeKind::Star { count: 6, .. }));
+}
+
+fn assert_fixture_styles(doc: &Document) {
+    let styled = named(doc, "Styled");
     assert_eq!(styled.styles.fill.as_deref(), Some("1:3"));
     assert!(doc.styles.iter().any(|s| s.name == "Brand"));
     let figcraft_doc::color::PaintKind::Solid { color } = styled.fills[0].kind else { panic!("not solid") };
     assert_eq!((color.r, color.g, color.b), (0.0, 0.0, 1.0));
-    // Variables, the fill binding and the explicit mode.
+}
+
+fn assert_fixture_variables(doc: &Document) {
+    let screen = named(doc, "Screen");
     assert_eq!(doc.variables.collections.len(), 1);
     assert_eq!(doc.variables.variables[0].kind, VariableType::Color);
-    assert_eq!(named(&doc, "Card").bound.get("fill").map(String::as_str), Some("1:5"));
+    assert_eq!(named(doc, "Card").bound.get("fill").map(String::as_str), Some("1:5"));
     assert_eq!(screen.var_modes.get("1:4").map(String::as_str), Some("1:91"));
-    // The component keeps its property; the instance is expanded with the value applied.
-    let button = named(&doc, "Button");
+}
+
+fn assert_fixture_components(doc: &Document) {
+    let button = named(doc, "Button");
     assert!(matches!(&button.kind, NodeKind::Component { props, .. } if props.contains_key("Show icon")));
-    let inst = named(&doc, "Button copy");
+    let inst = named(doc, "Button copy");
     assert!(
         matches!(&inst.kind, NodeKind::Instance { component, props } if *component == button.id && props.get("Show icon") == Some(&Value::Bool(false)))
     );
     let icon = doc.node(inst.children[0]).unwrap();
     assert_eq!(icon.name, "Renamed icon");
     assert!(!icon.visible);
-    // Prototype: the click navigates to the component; the flow starts at the screen.
-    let it = &photo.interactions[0];
+}
+
+fn assert_fixture_prototype(doc: &Document, warnings: &[String]) {
+    let button = named(doc, "Button");
+    let screen = named(doc, "Screen");
+    let it = &named(doc, "Photo").interactions[0];
     assert!(matches!(it.actions[0], figcraft_doc::proto::Action::Navigate { dest, .. } if dest == button.id));
     assert_eq!(it.duration_ms, 250);
     let Some(NodeKind::Page { flows, .. }) = doc.node(doc.pages[0]).map(|p| &p.kind) else { panic!("no page") };
     assert_eq!((flows[0].name.as_str(), flows[0].start), ("Main flow", screen.id));
     // The sticky note comes in as a frame, with a warning.
-    assert!(matches!(named(&doc, "Note").kind, NodeKind::Frame));
+    assert!(matches!(named(doc, "Note").kind, NodeKind::Frame));
     assert!(warnings.iter().any(|w| w.contains("sticky layers import as frames")), "{warnings:?}");
 }
 
diff --git a/crates/format/src/rest/proto_variable_tests.rs b/crates/format/src/rest/proto_variable_tests.rs
--- a/crates/format/src/rest/proto_variable_tests.rs
+++ b/crates/format/src/rest/proto_variable_tests.rs
@@ -209,8 +209,14 @@ fn embedded_variables_match_separate_ones() {
 #[test]
 fn interactions_triggers_actions_and_transitions() {
     let (doc, warnings) = import_with(&file_json(), Some(&variables_json()), &no_images).unwrap();
-    let id = |n: &str| named(&doc, n).id;
-    let button = named(&doc, "Button");
+    assert_interactions(&doc, &warnings);
+    assert_legacy_transition(&doc);
+    assert_interactions_without_variables();
+}
+
+fn assert_interactions(doc: &Document, warnings: &[String]) {
+    let id = |n: &str| named(doc, n).id;
+    let button = named(doc, "Button");
     let its = &button.interactions;
     assert_eq!(its.len(), 8, "{its:#?}");
 
@@ -239,18 +245,22 @@ fn interactions_triggers_actions_and_transitions() {
     assert_eq!(its[7].trigger, Trigger::MouseUp);
     assert_eq!(its[7].actions, [Action::ScrollTo { dest: id("Home"), offset: [0.0, 0.0] }], "unresolved destinations are dropped");
 
-    // The legacy single transition.
-    let details = named(&doc, "Details");
+    assert!(!has(warnings, "modifier keys"), "{warnings:?}");
+    for w in ["video triggers", "without a destination"] {
+        assert!(has(warnings, w), "{w}: {warnings:?}");
+    }
+}
+
+fn assert_legacy_transition(doc: &Document) {
+    let id = |n: &str| named(doc, n).id;
+    let details = named(doc, "Details");
     assert_eq!(details.interactions.len(), 1);
     let legacy = &details.interactions[0];
     assert_eq!((&legacy.trigger, &legacy.actions[..]), (&Trigger::OnClick, &[Action::Navigate { dest: id("Home"), preserve_scroll: false }][..]));
     assert_eq!((legacy.transition, legacy.duration_ms, legacy.easing), (Transition::Dissolve, 200, Easing::EaseIn));
+}
 
-    assert!(!has(&warnings, "modifier keys"), "{warnings:?}");
-    for w in ["video triggers", "without a destination"] {
-        assert!(has(&warnings, w), "{w}: {warnings:?}");
-    }
-    // Without variables, set-variable actions have nothing to set.
+fn assert_interactions_without_variables() {
     let (doc, warnings) = import(&file_json(), &no_images).unwrap();
     assert_eq!(named(&doc, "Button").interactions.len(), 6);
     assert!(has(&warnings, "set-variable actions") && has(&warnings, "set-variable-mode actions"), "{warnings:?}");
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
@@ -333,6 +333,154 @@ struct Item {
     base_off: f64,
 }
 
+/// Split flow items into lines, preserving a single empty line when wrapping is off.
+fn flow_lines(items: &[Item], wraps: bool, gap: f64, inner_main: f64) -> Vec<Vec<usize>> {
+    if !wraps {
+        return vec![(0..items.len()).collect()];
+    }
+    let mut lines = Vec::new();
+    let mut current = Vec::new();
+    let mut used = 0.0;
+    for (i, item) in items.iter().enumerate() {
+        let size = if item.fill_main { item.min_main.unwrap_or(0.0) } else { item.main };
+        let added = if current.is_empty() { size } else { gap.max(0.0) + size };
+        if !current.is_empty() && used + added > inner_main + 1e-6 {
+            lines.push(std::mem::take(&mut current));
+            used = size;
+        } else {
+            used += added;
+        }
+        current.push(i);
+    }
+    if !current.is_empty() {
+        lines.push(current);
+    }
+    lines
+}
+
+/// Resolve each line's main extent and baseline-adjusted cross extent.
+fn flow_line_extents(items: &mut [Item], lines: &[Vec<usize>], gap: f64) -> Vec<(f64, f64)> {
+    let mut extents = Vec::with_capacity(lines.len());
+    for line in lines {
+        let gaps = gap * line.len().saturating_sub(1) as f64;
+        let line_base = line.iter().filter_map(|i| items.get(*i)).filter_map(|item| item.baseline).fold(0.0, f64::max);
+        for i in line {
+            if let Some(item) = items.get_mut(*i) {
+                item.base_off = item.baseline.map(|baseline| line_base - baseline).unwrap_or(0.0);
+            }
+        }
+        let in_line = || line.iter().filter_map(|i| items.get(*i));
+        let main_used = in_line().map(|item| item.main).sum::<f64>() + gaps;
+        let all_fill = in_line().all(|item| item.fill_cross);
+        let cross = in_line().filter(|item| all_fill || !item.fill_cross).map(|item| item.base_off + item.cross).fold(0.0, f64::max);
+        extents.push((main_used, cross));
+    }
+    extents
+}
+
+/// Resolve cross-axis line spacing and alignment before positioning the flow items.
+fn align_flow_lines(items: &[Item], extents: &mut [(f64, f64)], al: &AutoLayout, inner_cross: f64, cross_gap: f64) -> (f64, f64) {
+    if extents.len() <= 1 {
+        return (0.0, cross_gap);
+    }
+    let lines_cross: f64 = extents.iter().map(|line| line.1).sum();
+    let between = (extents.len() - 1) as f64;
+    let spread = (inner_cross - lines_cross) / between;
+    if al.content_align == ContentAlign::SpaceBetween && spread > 0.0 {
+        return (0.0, spread);
+    }
+    let cross_gap = if al.content_align == ContentAlign::SpaceBetween { 0.0 } else { cross_gap };
+    let free = inner_cross - lines_cross - cross_gap * between;
+    if free > 0.0 && items.iter().all(|item| item.fill_cross) {
+        let extra = free / extents.len() as f64;
+        extents.iter_mut().for_each(|line| line.1 += extra);
+        return (0.0, cross_gap);
+    }
+    let offset = match al.cross_align {
+        CrossAlign::Min | CrossAlign::Baseline => 0.0,
+        CrossAlign::Center => free / 2.0,
+        CrossAlign::Max => free,
+    };
+    (offset, cross_gap)
+}
+
+/// Collect the size and sizing constraints of the children in flow order.
+fn flow_items(doc: &Document, kids: &[NodeId], horizontal: bool, hug_main: bool, hug_cross: bool) -> Vec<Item> {
+    let mut items = Vec::with_capacity(kids.len());
+    for child in kids {
+        let Some(node) = doc.node(*child) else { continue };
+        let (w, h) = outer_size(doc, *child);
+        let cl = &node.child_layout;
+        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) };
+        items.push(Item {
+            id: *child,
+            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,
+            min_main,
+            max_main,
+            min_cross,
+            max_cross,
+            baseline: None,
+            base_off: 0.0,
+        });
+    }
+    items
+}
+
+/// Position one resolved flow line, resizing wrapped fill children across the line.
+fn place_flow_line(doc: &mut Document, items: &[Item], line: &[usize], al: &AutoLayout, m: &dyn Measure, geometry: FlowLineGeometry) {
+    let FlowLineGeometry { horizontal, wraps, main_start, inner_main, cross_pos, line_cross, used, gap } = geometry;
+    let total = used - gap * line.len().saturating_sub(1) as f64;
+    let (mut pos, step_gap) = main_offsets(al.main_align, inner_main, total, line.len(), gap);
+    pos += main_start;
+    for i in line {
+        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,
+        };
+        if it.fill_cross && wraps {
+            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) };
+        place(doc, it.id, x, y);
+        pos += it.main + step_gap;
+    }
+}
+
+struct FlowLineGeometry {
+    horizontal: bool,
+    wraps: bool,
+    main_start: f64,
+    inner_main: f64,
+    cross_pos: f64,
+    line_cross: f64,
+    used: f64,
+    gap: f64,
+}
+
+/// Distribute a line's remaining main-axis space among its fill children.
+fn distribute_flow_lines(items: &mut [Item], lines: &[Vec<usize>], inner_main: f64, gap: f64, auto_gap: bool) {
+    for line in lines {
+        let fixed: f64 = line.iter().filter_map(|i| items.get(*i)).filter(|item| !item.fill_main).map(|item| item.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(|item| item.fill_main)).collect();
+        if !fills.is_empty() {
+            distribute(items, fills, inner_main - fixed - if auto_gap { 0.0 } else { gaps });
+        }
+    }
+}
+
 /// Lay out one auto layout frame's children and size the frame if it hugs.
 pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {
     let Some(_nested) = Nested::enter() else { return };
@@ -364,29 +512,7 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {
     let hug_cross = if horizontal { hug_h } else { hug_w };
     let baseline = horizontal && al.cross_align == CrossAlign::Baseline;
 
-    let mut items: Vec<Item> = Vec::with_capacity(kids.len());
-    for c in &kids {
-        let Some(n) = doc.node(*c) else { continue };
-        let (w, h) = outer_size(doc, *c);
-        let cl = &n.child_layout;
-        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) };
-        items.push(Item {
-            id: *c,
-            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,
-            min_main,
-            max_main,
-            min_cross,
-            max_cross,
-            baseline: None,
-            base_off: 0.0,
-        });
-    }
+    let mut items = flow_items(doc, &kids, horizontal, hug_main, hug_cross);
 
     let gap = finite_or_zero(al.gap);
     let auto_gap = al.main_align.is_auto_gap();
@@ -405,37 +531,10 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {
     }
 
     // Split into lines along the main axis (rows of a horizontal layout, columns of a vertical one).
-    let mut lines: Vec<Vec<usize>> = Vec::new();
-    if wraps {
-        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 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));
-                used = sz;
-            } else {
-                used += add;
-            }
-            cur.push(i);
-        }
-        if !cur.is_empty() {
-            lines.push(cur);
-        }
-    } else {
-        lines.push((0..items.len()).collect());
-    }
+    let lines = flow_lines(&items, wraps, gap, inner_main_fixed);
 
     // 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 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() {
-            distribute(&mut items, fills, inner_main_fixed - fixed - if auto_gap { 0.0 } else { gaps });
-        }
-    }
+    distribute_flow_lines(&mut items, &lines, inner_main_fixed, gap, auto_gap);
     // 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) {
@@ -452,23 +551,7 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {
     }
 
     // Line extents: (main used, cross size).
-    let mut line_extents: Vec<(f64, f64)> = Vec::new();
-    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);
-        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);
-            }
-        }
-        let in_line = || line.iter().filter_map(|i| items.get(*i));
-        let main_used: f64 = in_line().map(|it| it.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);
-        line_extents.push((main_used, cross));
-    }
+    let mut line_extents = flow_line_extents(&mut items, &lines, gap);
 
     // Container size (on a hugging axis fill children can't fill: they count with their own size).
     let content_main = line_extents.iter().map(|l| l.0).fold(0.0, f64::max);
@@ -500,57 +583,15 @@ 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.
-    let mut cross_pos = if horizontal { pt } else { pl };
+    let (cross_offset, cross_gap) = align_flow_lines(&items, &mut line_extents, &al, inner_cross, cross_gap);
+    let mut cross_pos = (if horizontal { pt } else { pl }) + cross_offset;
     let single = lines.len() == 1;
-    let mut cross_gap = cross_gap;
-    if lines.len() > 1 {
-        let lines_cross: f64 = line_extents.iter().map(|l| l.1).sum();
-        let between = (lines.len() - 1) as f64;
-        let spread = (inner_cross - lines_cross) / between;
-        if al.content_align == ContentAlign::SpaceBetween && spread > 0.0 {
-            cross_gap = spread;
-        } else {
-            if al.content_align == ContentAlign::SpaceBetween {
-                cross_gap = 0.0;
-            }
-            let free = inner_cross - lines_cross - cross_gap * between;
-            if free > 0.0 && items.iter().all(|it| it.fill_cross) {
-                let extra = free / lines.len() as f64;
-                line_extents.iter_mut().for_each(|l| l.1 += extra);
-            } else {
-                cross_pos += match al.cross_align {
-                    CrossAlign::Min | CrossAlign::Baseline => 0.0,
-                    CrossAlign::Center => free / 2.0,
-                    CrossAlign::Max => free,
-                };
-            }
-        }
-    }
     for (li, line) in lines.iter().enumerate() {
         let (used, line_cross) = line_extents.get(li).copied().unwrap_or((0.0, 0.0));
         let line_cross = if single { inner_cross } else { line_cross };
-        let total = used - gap * line.len().saturating_sub(1) as f64;
-        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 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,
-            };
-            // Fill across a wrapped line: its size is known only now.
-            if it.fill_cross && wraps {
-                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) };
-            place(doc, it.id, x, y);
-            pos += it.main + step_gap;
-        }
+        let geometry =
+            FlowLineGeometry { horizontal, wraps, main_start: if horizontal { pl } else { pt }, inner_main, cross_pos, line_cross, used, gap };
+        place_flow_line(doc, &items, line, &al, m, geometry);
         cross_pos += line_cross + cross_gap;
     }
 }
diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs
--- a/crates/text/src/layout.rs
+++ b/crates/text/src/layout.rs
@@ -322,9 +322,16 @@ pub(crate) fn visual_order(levels: &[u8]) -> Vec<usize> {
     order
 }
 
-/// Lay out `text` for a box of `box_width` (used when the text wraps or aligns; for auto width
-/// pass `None`). `box_height` is used for truncation only.
-pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>) -> TextLayout {
+struct ShapedText {
+    chars: Vec<char>,
+    styles: Vec<TextStyle>,
+    faces: Vec<Option<Arc<Face>>>,
+    fallbacks: Vec<Arc<Face>>,
+    items: Vec<Item>,
+    bidi: Option<Bidi>,
+}
+
+fn shape_text(text: &TextData) -> ShapedText {
     let chars: Vec<char> = text.characters.chars().collect();
     let spans = text.spans();
     let mut styles: Vec<TextStyle> = Vec::new();
@@ -365,7 +372,6 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
     // Shape each span, split by paragraph, bidi run and fallback face.
     let bidi = Bidi::of(&text.characters);
     let level_at = |i: usize| bidi.as_ref().and_then(|b| b.levels.get(i)).copied().unwrap_or(0);
-    let rtl_para_at = |i: usize| bidi.as_ref().and_then(|b| b.rtl_para.get(i)).copied().unwrap_or(false);
     // A grapheme cluster (emoji sequence, letter with marks) is shaped with one face.
     let cluster_start = crate::breaks::grapheme_starts(&text.characters);
     let mut items: Vec<Item> = Vec::with_capacity(chars.len());
@@ -431,40 +437,21 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
         }
     }
 
-    // List markers sit in a gutter (one more per nesting level) and wrapped lines hang at the
-    // text start; a hanging list puts the outermost gutter outside the box. Paragraph indent
-    // applies to plain paragraphs only. Right-to-left paragraphs mirror both: the gutter and the
-    // indent are on the right, the text ends where they start.
-    let lists: Vec<ParaList> = (0..text.paragraph_count()).map(|p| text.para_list(p)).collect();
-    let markers = list_markers(&lists);
-    let step = list_step(&lists, &markers, styles.first(), faces.first().cloned().flatten().as_ref());
-    let hang_list = if text.hanging_list { step } else { 0.0 };
-    // Paragraph indent applies to start-aligned text only (left, or right for a right-to-left paragraph).
-    let indent_px = if text.paragraph_indent.is_finite() { text.paragraph_indent.clamp(0.0, 10_000.0) } else { 0.0 };
-    let indent_at = |char_idx: usize| {
-        let start_aligned = if rtl_para_at(char_idx) { text.align_h == TextAlignH::Right } else { text.align_h == TextAlignH::Left };
-        if start_aligned { indent_px } else { 0.0 }
-    };
-    let list_of = |p: usize| lists.get(p).copied().unwrap_or_default();
-    // The start indent of a line of paragraph `p` (`first`: its first line, starting at char `ci`).
-    let line_indent_of = |p: usize, first: bool, ci: usize| {
-        let pl = list_of(p);
-        match pl.list {
-            ListStyle::None if first => indent_at(ci),
-            ListStyle::None => 0.0,
-            _ => step * (f64::from(pl.indent) + 1.0) - hang_list,
-        }
-    };
-    // Hanging punctuation: at the start edge of left-aligned and justified lines, at the end edge
-    // of right-aligned and justified ones (left-to-right paragraphs).
-    let hang_start = text.hanging_punctuation && matches!(text.align_h, TextAlignH::Left | TextAlignH::Justified);
-    let hang_end = text.hanging_punctuation && matches!(text.align_h, TextAlignH::Right | TextAlignH::Justified);
+    ShapedText { chars, styles, faces, fallbacks, items, bidi }
+}
 
-    // Line breaking.
-    let wrap = match text.auto_resize {
-        TextAutoResize::WidthAndHeight => None,
-        _ => box_width.filter(|w| w.is_finite() && *w > 0.0),
-    };
+fn line_break_after(items: &[Item], end: usize) -> bool {
+    end.checked_sub(1).and_then(|i| items.get(i)).is_some_and(|item| is_line_break(item.ch))
+}
+
+fn break_lines(
+    items: &[Item],
+    wrap: Option<f64>,
+    line_indent_of: &impl Fn(usize, bool, usize) -> f64,
+    rtl_para_at: &impl Fn(usize) -> bool,
+    hang_start: bool,
+    hang_end: bool,
+) -> Vec<(usize, usize, bool, usize)> {
     let mut lines: Vec<(usize, usize, bool, usize)> = Vec::new(); // item range, paragraph end, paragraph
     let mut line_start = 0;
     let mut i = 0;
@@ -521,11 +508,259 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
         }
         i += 1;
     }
-    let break_after = |e: usize| e.checked_sub(1).and_then(|i| items.get(i)).is_some_and(|it| is_line_break(it.ch));
-    if line_start < items.len() || lines.is_empty() || lines.last().is_some_and(|l| l.2 || break_after(l.1)) {
+    if line_start < items.len() || lines.is_empty() || lines.last().is_some_and(|l| l.2 || line_break_after(items, l.1)) {
         lines.push((line_start, items.len(), true, para));
     }
 
+    lines
+}
+
+#[derive(Clone, Copy)]
+struct LayoutFinish<'a> {
+    text: &'a TextData,
+    box_width: Option<f64>,
+    box_height: Option<f64>,
+    wrap: Option<f64>,
+    lines: &'a [(usize, usize, bool, usize)],
+    items: &'a [Item],
+    styles: &'a [TextStyle],
+    faces: &'a [Option<Arc<Face>>],
+    first_cap: f64,
+    flow_height: f64,
+}
+
+fn glyph_at(styles: &[TextStyle], item: &Item, pen: f64, baseline: f64) -> Option<Glyph> {
+    let size = styles.get(item.style).map(|st| sanitize_size(st.font_size)).unwrap_or(12.0) * item.scale;
+    (item.gid != 0 || !item.is_space()).then(|| Glyph {
+        face: item.face.clone(),
+        coords: item.coords.clone(),
+        gid: item.gid,
+        x: pen + item.dx,
+        y: baseline - item.dy,
+        size,
+        advance: item.adv,
+        char_start: item.char_start,
+        char_end: item.char_end,
+        style: item.style,
+        rtl: item.level % 2 == 1,
+        warp: Affine::IDENTITY,
+    })
+}
+
+fn truncate_layout(out: &mut TextLayout, finish: &LayoutFinish<'_>) {
+    let LayoutFinish { text, box_height, wrap, lines, items, styles, .. } = *finish;
+    let trim = text.leading_trim == LeadingTrim::CapHeight && text.path.is_none();
+    let bottom = |line: &Line| if trim { line.baseline } else { line.top + line.height };
+    // Truncation by height.
+    if matches!(text.auto_resize, TextAutoResize::Truncate)
+        && let Some(h) = box_height
+        && out.height > h + 0.5
+    {
+        let keep = out.lines.iter().take_while(|l| bottom(l) <= h + 0.5).count().max(1);
+        if keep < out.lines.len() {
+            out.lines.truncate(keep);
+            let gend = out.lines.last().map(|l| l.glyph_end).unwrap_or(0);
+            out.glyphs.truncate(gend);
+            out.truncated = true;
+            out.height = out.lines.last().map_or(0.0, bottom);
+        }
+    }
+    if out.truncated {
+        // The last line shown runs on into the line cut after it, so the ellipsis fills the line
+        // ("Lorem consec…") instead of following the last whole word ("Lorem…").
+        let last = out.lines.len().checked_sub(1);
+        let wrapped_on = last.and_then(|li| Some((out.lines.get(li)?, lines.get(li)?, lines.get(li + 1)?)));
+        if let (Some(maxw), Some((line, &(_, e, para_end, _), &(ns, ne, _, _)))) = (wrap, wrapped_on)
+            && !para_end
+            && !line_break_after(items, e)
+            && !line.rtl
+        {
+            let baseline = line.baseline;
+            let mut pen = out.glyphs.get(line.glyph_start..line.glyph_end).and_then(<[Glyph]>::last).map_or(line.width, |g| g.x + g.advance);
+            for it in items.get(ns..ne).unwrap_or(&[]) {
+                if pen > maxw {
+                    break;
+                }
+                if !is_line_break(it.ch) {
+                    out.glyphs.extend(glyph_at(styles, it, pen, baseline));
+                    pen += it.adv;
+                }
+            }
+            let gend = out.glyphs.len();
+            if let Some(l) = out.lines.last_mut() {
+                l.glyph_end = gend;
+            }
+        }
+        add_ellipsis(out, wrap);
+        // Carets and selections reach every char the line shows, borrowed ones included.
+        if let Some(l) = out.lines.last() {
+            let shown =
+                out.glyphs.get(l.glyph_start..l.glyph_end).unwrap_or(&[]).iter().filter(|g| g.char_end > g.char_start).map(|g| g.char_end).max();
+            if let (Some(shown), Some(l)) = (shown, out.lines.last_mut()) {
+                l.char_end = l.char_end.max(shown);
+            }
+        }
+    }
+}
+
+fn finish_layout(out: &mut TextLayout, finish: LayoutFinish<'_>) {
+    let LayoutFinish { text, box_width, faces, first_cap, flow_height, .. } = finish;
+    // Vertical trim: the box starts at the first line's cap height and ends at the last baseline.
+    let trim = text.leading_trim == LeadingTrim::CapHeight && text.path.is_none();
+    if trim && let Some(first) = out.lines.first() {
+        let shift = first.baseline - first_cap;
+        for l in &mut out.lines {
+            l.top -= shift;
+            l.baseline -= shift;
+        }
+        for g in &mut out.glyphs {
+            g.y -= shift;
+        }
+    }
+    let bottom = |l: &Line| if trim { l.baseline } else { l.top + l.height };
+    out.height = out.lines.last().map_or(flow_height, bottom).max(0.0);
+
+    truncate_layout(out, &finish);
+
+    // Horizontal alignment within the box.
+    let align_width = match text.auto_resize {
+        TextAutoResize::WidthAndHeight => out.width,
+        _ => box_width.unwrap_or(out.width),
+    };
+    for li in 0..out.lines.len() {
+        let Some(line) = out.lines.get(li) else { continue };
+        let dx = match text.align_h {
+            TextAlignH::Left | TextAlignH::Justified => 0.0,
+            TextAlignH::Center => (align_width - line.width) / 2.0,
+            TextAlignH::Right => align_width - line.width,
+        };
+        if dx != 0.0 {
+            let (gs, ge) = (line.glyph_start, line.glyph_end);
+            for g in out.glyphs.get_mut(gs..ge).unwrap_or(&mut []) {
+                g.x += dx;
+            }
+            if let Some(l) = out.lines.get_mut(li) {
+                l.x = dx;
+            }
+        }
+    }
+
+    // Decorations per contiguous glyph run of the same style on a line; a decoration colour
+    // paints with a style of its own, added after the glyphs' styles.
+    let mut decos = Vec::new();
+    let mut deco_styles: Vec<TextStyle> = Vec::new();
+    for line in &out.lines {
+        let gl = out.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]);
+        let mut j = 0;
+        while j < gl.len() {
+            let Some(g0) = gl.get(j) else { break };
+            // List markers and the ellipsis are not decorated.
+            if g0.char_end == g0.char_start {
+                j += 1;
+                continue;
+            }
+            let st = out.styles.get(g0.style).cloned().unwrap_or_default();
+            let mut k = j + 1;
+            while gl.get(k).is_some_and(|g| g.style == g0.style && g.char_end > g.char_start) {
+                k += 1;
+            }
+            if st.decoration != TextDecoration::None
+                && let Some(last) = gl.get(k - 1)
+            {
+                let x0 = g0.x;
+                let x1 = last.x + last.advance;
+                // The style's own font and size (not a fallback face or a small-cap scale), so the
+                // line runs at one height and weight through mixed scripts.
+                let f = faces.get(g0.style).and_then(Option::as_ref).unwrap_or(&g0.face);
+                let size = sanitize_size(st.font_size);
+                let s = size / f.upem;
+                let look = st.look();
+                let th = deco_len(look.thickness, size, (f.underline_thickness * s).max(0.5)).clamp(0.1, size);
+                let underline = st.decoration == TextDecoration::Underline;
+                // The line's top edge (an underline's own offset is measured from the baseline).
+                let top = match look.offset {
+                    DecorationLength::Auto => line.baseline - if underline { f.underline_pos } else { f.strike_pos } * s - th / 2.0,
+                    m if underline => line.baseline + deco_len(m, size, 0.0).clamp(-size, size),
+                    _ => line.baseline - f.strike_pos * s - th / 2.0,
+                };
+                let style = match look.color {
+                    Some(c) => {
+                        let own = TextStyle { fills: Some(vec![figcraft_doc::color::Paint::solid(c)]), ..st.clone() };
+                        let k = deco_styles.iter().position(|d| *d == own).unwrap_or_else(|| {
+                            deco_styles.push(own);
+                            deco_styles.len() - 1
+                        });
+                        out.styles.len() + k
+                    }
+                    None => g0.style,
+                };
+                let swing = if look.style == DecorationStyle::Wavy { wave(th).0 } else { 0.0 };
+                // Skip ink: the underline stops a little before each glyph crossing it.
+                let gap = th.max(1.0);
+                let cuts = if underline && look.skip_ink {
+                    ink_cuts(gl.get(j..k).unwrap_or(&[]), top - gap / 2.0 - swing, top + th + gap / 2.0 + swing, gap)
+                } else {
+                    Vec::new()
+                };
+                let mut from = x0;
+                for (c0, c1) in cuts.into_iter().chain([(x1, x1)]) {
+                    if c0.min(x1) > from + 0.5 {
+                        let rect = Rect::new(from, top - swing, c0.min(x1), top + th + swing);
+                        decos.push(Decoration { rect, style, kind: look.style, thickness: th });
+                    }
+                    from = from.max(c1);
+                }
+            }
+            j = k;
+        }
+    }
+    out.decorations = decos;
+    out.styles.extend(deco_styles);
+    if let Some(tp) = &text.path {
+        crate::onpath::place(out, tp, text.align_h);
+    }
+}
+
+/// Lay out `text` for a box of `box_width` (used when the text wraps or aligns; for auto width
+/// pass `None`). `box_height` is used for truncation only.
+pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>) -> TextLayout {
+    let ShapedText { chars, styles, faces, fallbacks, items, bidi } = shape_text(text);
+    let rtl_para_at = |i: usize| bidi.as_ref().and_then(|b| b.rtl_para.get(i)).copied().unwrap_or(false);
+
+    // List markers sit in a gutter (one more per nesting level) and wrapped lines hang at the
+    // text start; a hanging list puts the outermost gutter outside the box. Paragraph indent
+    // applies to plain paragraphs only. Right-to-left paragraphs mirror both: the gutter and the
+    // indent are on the right, the text ends where they start.
+    let lists: Vec<ParaList> = (0..text.paragraph_count()).map(|p| text.para_list(p)).collect();
+    let markers = list_markers(&lists);
+    let step = list_step(&lists, &markers, styles.first(), faces.first().cloned().flatten().as_ref());
+    let hang_list = if text.hanging_list { step } else { 0.0 };
+    // Paragraph indent applies to start-aligned text only (left, or right for a right-to-left paragraph).
+    let indent_px = if text.paragraph_indent.is_finite() { text.paragraph_indent.clamp(0.0, 10_000.0) } else { 0.0 };
+    let indent_at = |char_idx: usize| {
+        let start_aligned = if rtl_para_at(char_idx) { text.align_h == TextAlignH::Right } else { text.align_h == TextAlignH::Left };
+        if start_aligned { indent_px } else { 0.0 }
+    };
+    let list_of = |p: usize| lists.get(p).copied().unwrap_or_default();
+    // The start indent of a line of paragraph `p` (`first`: its first line, starting at char `ci`).
+    let line_indent_of = |p: usize, first: bool, ci: usize| {
+        let pl = list_of(p);
+        match pl.list {
+            ListStyle::None if first => indent_at(ci),
+            ListStyle::None => 0.0,
+            _ => step * (f64::from(pl.indent) + 1.0) - hang_list,
+        }
+    };
+    // Hanging punctuation: at the start edge of left-aligned and justified lines, at the end edge
+    // of right-aligned and justified ones (left-to-right paragraphs).
+    let hang_start = text.hanging_punctuation && matches!(text.align_h, TextAlignH::Left | TextAlignH::Justified);
+    let hang_end = text.hanging_punctuation && matches!(text.align_h, TextAlignH::Right | TextAlignH::Justified);
+
+    let wrap = match text.auto_resize {
+        TextAutoResize::WidthAndHeight => None,
+        _ => box_width.filter(|w| w.is_finite() && *w > 0.0),
+    };
+    let lines = break_lines(&items, wrap, &line_indent_of, &rtl_para_at, hang_start, hang_end);
     // Line metrics and positions.
     let default_style = styles.first().cloned().unwrap_or_default();
     let default_face = faces.first().cloned().flatten();
@@ -546,25 +781,6 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
             None => [size * 0.8, size * 0.2, whole_px(st.line_height.resolve(size, 1.2)), size * 0.7],
         }
     };
-    // An item's glyph with its pen position at `pen` on the line at `baseline` (none for a space
-    // the font has no glyph for).
-    let glyph_at = |it: &Item, pen: f64, baseline: f64| {
-        let size = styles.get(it.style).map(|st| sanitize_size(st.font_size)).unwrap_or(12.0) * it.scale;
-        (it.gid != 0 || !it.is_space()).then(|| Glyph {
-            face: it.face.clone(),
-            coords: it.coords.clone(),
-            gid: it.gid,
-            x: pen + it.dx,
-            y: baseline - it.dy,
-            size,
-            advance: it.adv,
-            char_start: it.char_start,
-            char_end: it.char_end,
-            style: it.style,
-            rtl: it.level % 2 == 1,
-            warp: Affine::IDENTITY,
-        })
-    };
     let mut first_cap = 0.0;
     for (li, (s, e, para_end, para)) in lines.iter().enumerate() {
         if li >= max_lines {
@@ -613,7 +829,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
         // Justify: extra space per gap.
         let gaps = items.get(*s..vis_end).unwrap_or(&[]).iter().filter(|it| it.stretches()).count();
         let justify_extra = match (text.align_h, wrap) {
-            (TextAlignH::Justified, Some(maxw)) if !para_end && !break_after(*e) && gaps > 0 => {
+            (TextAlignH::Justified, Some(maxw)) if !para_end && !line_break_after(&items, *e) && gaps > 0 => {
                 ((maxw - line_indent + hang_l + hang_r - width) / gaps as f64).max(0.0)
             }
             _ => 0.0,
@@ -704,7 +920,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
             if is_line_break(it.ch) {
                 continue;
             }
-            out.glyphs.extend(glyph_at(it, pen, baseline));
+            out.glyphs.extend(glyph_at(&styles, it, pen, baseline));
             pen += it.adv;
             if it.stretches() {
                 pen += justify_extra;
@@ -723,176 +939,18 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
             width: width + line_indent - hang_l - hang_r,
             x: 0.0,
             para_end: *para_end,
-            break_after: break_after(*e),
+            break_after: line_break_after(&items, *e),
             rtl: rtl_line,
         });
         y += lh;
         para_start_line = *para_end;
     }
     out.width = out.lines.iter().map(|l| l.width).fold(0.0, f64::max);
-    // Vertical trim: the box starts at the first line's cap height and ends at the last baseline.
-    let trim = text.leading_trim == LeadingTrim::CapHeight && text.path.is_none();
-    if trim && let Some(first) = out.lines.first() {
-        let shift = first.baseline - first_cap;
-        for l in &mut out.lines {
-            l.top -= shift;
-            l.baseline -= shift;
-        }
-        for g in &mut out.glyphs {
-            g.y -= shift;
-        }
-    }
-    let bottom = |l: &Line| if trim { l.baseline } else { l.top + l.height };
-    out.height = out.lines.last().map_or(y, bottom).max(0.0);
-
-    // Truncation by height.
-    if matches!(text.auto_resize, TextAutoResize::Truncate)
-        && let Some(h) = box_height
-        && out.height > h + 0.5
-    {
-        let keep = out.lines.iter().take_while(|l| bottom(l) <= h + 0.5).count().max(1);
-        if keep < out.lines.len() {
-            out.lines.truncate(keep);
-            let gend = out.lines.last().map(|l| l.glyph_end).unwrap_or(0);
-            out.glyphs.truncate(gend);
-            out.truncated = true;
-            out.height = out.lines.last().map_or(0.0, bottom);
-        }
-    }
-    if out.truncated {
-        // The last line shown runs on into the line cut after it, so the ellipsis fills the line
-        // ("Lorem consec…") instead of following the last whole word ("Lorem…").
-        let last = out.lines.len().checked_sub(1);
-        let wrapped_on = last.and_then(|li| Some((out.lines.get(li)?, lines.get(li)?, lines.get(li + 1)?)));
-        if let (Some(maxw), Some((line, &(_, e, para_end, _), &(ns, ne, _, _)))) = (wrap, wrapped_on)
-            && !para_end
-            && !break_after(e)
-            && !line.rtl
-        {
-            let baseline = line.baseline;
-            let mut pen = out.glyphs.get(line.glyph_start..line.glyph_end).and_then(<[Glyph]>::last).map_or(line.width, |g| g.x + g.advance);
-            for it in items.get(ns..ne).unwrap_or(&[]) {
-                if pen > maxw {
-                    break;
-                }
-                if !is_line_break(it.ch) {
-                    out.glyphs.extend(glyph_at(it, pen, baseline));
-                    pen += it.adv;
-                }
-            }
-            let gend = out.glyphs.len();
-            if let Some(l) = out.lines.last_mut() {
-                l.glyph_end = gend;
-            }
-        }
-        add_ellipsis(&mut out, wrap);
-        // Carets and selections reach every char the line shows, borrowed ones included.
-        if let Some(l) = out.lines.last() {
-            let shown =
-                out.glyphs.get(l.glyph_start..l.glyph_end).unwrap_or(&[]).iter().filter(|g| g.char_end > g.char_start).map(|g| g.char_end).max();
-            if let (Some(shown), Some(l)) = (shown, out.lines.last_mut()) {
-                l.char_end = l.char_end.max(shown);
-            }
-        }
-    }
+    finish_layout(
+        &mut out,
+        LayoutFinish { text, box_width, box_height, wrap, lines: &lines, items: &items, styles: &styles, faces: &faces, first_cap, flow_height: y },
+    );
 
-    // Horizontal alignment within the box.
-    let align_width = match text.auto_resize {
-        TextAutoResize::WidthAndHeight => out.width,
-        _ => box_width.unwrap_or(out.width),
-    };
-    for li in 0..out.lines.len() {
-        let Some(line) = out.lines.get(li) else { continue };
-        let dx = match text.align_h {
-            TextAlignH::Left | TextAlignH::Justified => 0.0,
-            TextAlignH::Center => (align_width - line.width) / 2.0,
-            TextAlignH::Right => align_width - line.width,
-        };
-        if dx != 0.0 {
-            let (gs, ge) = (line.glyph_start, line.glyph_end);
-            for g in out.glyphs.get_mut(gs..ge).unwrap_or(&mut []) {
-                g.x += dx;
-            }
-            if let Some(l) = out.lines.get_mut(li) {
-                l.x = dx;
-            }
-        }
-    }
-
-    // Decorations per contiguous glyph run of the same style on a line; a decoration colour
-    // paints with a style of its own, added after the glyphs' styles.
-    let mut decos = Vec::new();
-    let mut deco_styles: Vec<TextStyle> = Vec::new();
-    for line in &out.lines {
-        let gl = out.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]);
-        let mut j = 0;
-        while j < gl.len() {
-            let Some(g0) = gl.get(j) else { break };
-            // List markers and the ellipsis are not decorated.
-            if g0.char_end == g0.char_start {
-                j += 1;
-                continue;
-            }
-            let st = out.styles.get(g0.style).cloned().unwrap_or_default();
-            let mut k = j + 1;
-            while gl.get(k).is_some_and(|g| g.style == g0.style && g.char_end > g.char_start) {
-                k += 1;
-            }
-            if st.decoration != TextDecoration::None
-                && let Some(last) = gl.get(k - 1)
-            {
-                let x0 = g0.x;
-                let x1 = last.x + last.advance;
-                // The style's own font and size (not a fallback face or a small-cap scale), so the
-                // line runs at one height and weight through mixed scripts.
-                let f = faces.get(g0.style).and_then(Option::as_ref).unwrap_or(&g0.face);
-                let size = sanitize_size(st.font_size);
-                let s = size / f.upem;
-                let look = st.look();
-                let th = deco_len(look.thickness, size, (f.underline_thickness * s).max(0.5)).clamp(0.1, size);
-                let underline = st.decoration == TextDecoration::Underline;
-                // The line's top edge (an underline's own offset is measured from the baseline).
-                let top = match look.offset {
-                    DecorationLength::Auto => line.baseline - if underline { f.underline_pos } else { f.strike_pos } * s - th / 2.0,
-                    m if underline => line.baseline + deco_len(m, size, 0.0).clamp(-size, size),
-                    _ => line.baseline - f.strike_pos * s - th / 2.0,
-                };
-                let style = match look.color {
-                    Some(c) => {
-                        let own = TextStyle { fills: Some(vec![figcraft_doc::color::Paint::solid(c)]), ..st.clone() };
-                        let k = deco_styles.iter().position(|d| *d == own).unwrap_or_else(|| {
-                            deco_styles.push(own);
-                            deco_styles.len() - 1
-                        });
-                        out.styles.len() + k
-                    }
-                    None => g0.style,
-                };
-                let swing = if look.style == DecorationStyle::Wavy { wave(th).0 } else { 0.0 };
-                // Skip ink: the underline stops a little before each glyph crossing it.
-                let gap = th.max(1.0);
-                let cuts = if underline && look.skip_ink {
-                    ink_cuts(gl.get(j..k).unwrap_or(&[]), top - gap / 2.0 - swing, top + th + gap / 2.0 + swing, gap)
-                } else {
-                    Vec::new()
-                };
-                let mut from = x0;
-                for (c0, c1) in cuts.into_iter().chain([(x1, x1)]) {
-                    if c0.min(x1) > from + 0.5 {
-                        let rect = Rect::new(from, top - swing, c0.min(x1), top + th + swing);
-                        decos.push(Decoration { rect, style, kind: look.style, thickness: th });
-                    }
-                    from = from.max(c1);
-                }
-            }
-            j = k;
-        }
-    }
-    out.decorations = decos;
-    out.styles.extend(deco_styles);
-    if let Some(tp) = &text.path {
-        crate::onpath::place(&mut out, tp, text.align_h);
-    }
     out
 }
 
diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml
--- a/xtask/Cargo.toml
+++ b/xtask/Cargo.toml
@@ -5,6 +5,9 @@ edition.workspace = true
 license.workspace = true
 publish = false
 
+[lints]
+workspace = true
+
 [dependencies]
 serde_json = { workspace = true }
 figcraft-engine = { workspace = true }
