diff --git a/CHANGELOG.md b/CHANGELOG.md index 629c37c..2b633b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,18 @@ - **`box-sizing`** now applies ([#33](https://github.com/brewkits/hyper_render/issues/33)): `border-box` includes padding and border in `width` / `min-width` / `max-width`, including `%`, on in-flow blocks. Limits are in `doc/LIMITATIONS.md`. - **`position: relative`** now applies ([#34](https://github.com/brewkits/hyper_render/issues/34)): `top` / `right` / `bottom` / `left` (lengths) move blocks, inline spans and flex/grid items without affecting layout, and hit-testing and selection follow. Moved content paints above normal content and is clipped to the render box, so untrusted HTML can't paint over the host app. Other content stays unclipped. +- **Horizontal block margins** now apply: `margin-left`, `margin-right` and `margin: 0 auto` inset or centre in-flow blocks. They used to have no effect, so a `max-width` layout sat at the left edge. - **Rendering changes:** - A block's background and border now follow its `width`. They used to span the whole container. - `min-width: %` now applies on blocks. - A first block styled only with a background or border now paints it. - Gradients on canvas blocks and inline spans now paint. They painted nothing. - A `
` toggle no longer leaves a block's old background behind. + - A float now sits inside its containing block. It used to sit at the render box edge, ignoring a parent's padding. + - A `
` now fits inside its parent's padding instead of overflowing.
+  - In RTL, a block with a `width` anchors to the right edge.
+  - `margin` in `em` now uses the element's font size, not the root's.
+  - Inline `!important` no longer makes a value invalid: `style="margin-left:10px !important"` used to be dropped.
   - `HyperViewer(textDirection:)` and an ambient RTL `Directionality` now apply to short documents in sync mode. Virtualized and paged modes already honoured them.
 - **Requires `hyper_render_core` `^1.14.0`.**
 
diff --git a/doc/CSS_PROPERTIES_MATRIX.md b/doc/CSS_PROPERTIES_MATRIX.md
index 3654418..206b675 100644
--- a/doc/CSS_PROPERTIES_MATRIX.md
+++ b/doc/CSS_PROPERTIES_MATRIX.md
@@ -30,11 +30,11 @@ This document lists CSS property support in HyperRender.
 | `max-width` | ✅ | px, % | Constrains a block's content width (text wraps inside it) |
 | `min-height` | ❌ | — | Parsed into `ComputedStyle.minHeight`, then read by nothing in the render path. Found by `test/flagship_execution_audit_test.dart` |
 | `max-height` | ❌ | — | Parsed into `ComputedStyle.maxHeight`, then read by nothing in the render path. Found by `test/flagship_execution_audit_test.dart` |
-| `margin` | ✅ | px, %, auto | All 4 directions + shorthand |
-| `margin-top` | ✅ | px, %, auto | |
-| `margin-right` | ✅ | px, %, auto | |
-| `margin-bottom` | ✅ | px, %, auto | |
-| `margin-left` | ✅ | px, %, auto | |
+| `margin` | ✅ | px, em, rem, auto | All 4 directions + shorthand. `%` not supported. `auto` centres in-flow blocks with a width; see [LIMITATIONS.md](LIMITATIONS.md) |
+| `margin-top` | ✅ | px, em, rem | |
+| `margin-right` | ✅ | px, em, rem, auto | |
+| `margin-bottom` | ✅ | px, em, rem | |
+| `margin-left` | ✅ | px, em, rem, auto | |
 | `padding` | ✅ | px, % | All 4 directions + shorthand |
 | `padding-top` | ✅ | px, % | |
 | `padding-right` | ✅ | px, % | |
diff --git a/doc/LIMITATIONS.md b/doc/LIMITATIONS.md
index fb56b06..e10e714 100644
--- a/doc/LIMITATIONS.md
+++ b/doc/LIMITATIONS.md
@@ -47,7 +47,7 @@ are silently ignored.
 |----------|------------|
 | `opacity` | Applied per-element; stacking context opacity not propagated |
 | `box-sizing: border-box` | Applied on in-flow blocks only. Borders don't reserve layout space, so text can overlap a border. A flex item's `width` already includes its padding whatever `box-sizing` says. `width` has no effect on a flex or grid container, `` or `
`, and floated images ignore padding, so `box-sizing` changes nothing there | -| Horizontal block `margin` | `margin-left`, `margin-right` and `margin: 0 auto` have no effect on an in-flow block, so a block with a `width` always sits at the left edge and can't be centred this way | +| Horizontal `margin` | Applies to in-flow blocks and flex/grid containers. `%` is not supported. `auto` works on in-flow blocks with a `width`, `max-width` or `min-width`, but not on flex/grid containers or items, tables or images. Negative margins can reach into a parent's padding but stay inside the render box. A large container flattened for virtualization loses its margins and width, so a long `max-width; margin: 0 auto` article renders full-width: set them on its children, or use `mode: HyperRenderMode.sync` | | `position: relative` | Lengths only: `%` offsets are ignored. Moved content paints above normal content but in no finer order (no `z-index` or stacking contexts), and is clipped to the render box so it can't paint over the host app (the sanitizer doesn't filter CSS properties). In virtualized mode that box is one chunk. A flex or grid item moved past its container is still visible inside the box. Not applied to ``. A large container flattened for virtualization loses its offset | | `calc()` | Arithmetic on px/em/rem only; `%` in calc not resolved | | `text-align` | Center/right/justify on canvas paragraphs, LTR and box-level RTL (`textDirection: rtl`). An explicit `text-align` overrides RTL's default right edge; `justify` in RTL is not yet distributed (keeps the start edge) | diff --git a/packages/hyper_render_core/CHANGELOG.md b/packages/hyper_render_core/CHANGELOG.md index d71e9b7..8197135 100644 --- a/packages/hyper_render_core/CHANGELOG.md +++ b/packages/hyper_render_core/CHANGELOG.md @@ -4,7 +4,8 @@ - **`box-sizing`:** new `ComputedStyle.boxSizing` / `HyperBoxSizing`. `border-box` applies to `width` / `min-width` / `max-width`, including `%`, on in-flow blocks. - **`position: relative`:** `top` / `right` / `bottom` / `left` (lengths) move blocks, inline spans and flex/grid items without affecting layout. New `ComputedStyle.insetTop` / `insetRight` / `insetBottom` / `insetLeft` and `relativeOffset()`. Hit-testing and selection follow; moved content paints above normal content, clipped to the render box. -- **Behavior changes:** a block's background and border follow its `width`; `min-width: %` applies on blocks; a first block styled only with a background or border paints it; canvas gradients paint; a `
` toggle no longer leaves an old background behind. +- **Horizontal block margins:** `margin-left`, `margin-right` and `margin: 0 auto` now apply to in-flow blocks. New `ComputedStyle.marginLeftAuto` / `marginRightAuto`. +- **Behavior changes:** a block's background and border follow its `width`; `min-width: %` applies on blocks; a first block styled only with a background or border paints it; canvas gradients paint; a `
` toggle no longer leaves an old background behind. Floats sit inside their containing block, `
` fits its parent's padding, RTL blocks with a `width` anchor right, `margin` `em` uses the element's font size, and inline `!important` no longer invalidates a value.
 
 ## 1.13.0
 
diff --git a/packages/hyper_render_core/lib/src/core/render_hyper_box.dart b/packages/hyper_render_core/lib/src/core/render_hyper_box.dart
index fd7f0c8..16365ee 100644
--- a/packages/hyper_render_core/lib/src/core/render_hyper_box.dart
+++ b/packages/hyper_render_core/lib/src/core/render_hyper_box.dart
@@ -1170,15 +1170,35 @@ class RenderHyperBox extends RenderBox
     // magnitude too large for documents with many paragraphs.
     double maxWidth = 0;
     double lineWidth = 0;
+    // Horizontal padding and margins of the open blocks. Layout takes these off
+    // the line, so a host sizing to this width must leave room for them.
+    final insets = <(UDTNode, double)>[];
 
     void flushLine() {
-      if (lineWidth > maxWidth) maxWidth = lineWidth;
+      final total = lineWidth > 0
+          ? lineWidth + (insets.isEmpty ? 0 : insets.last.$2)
+          : 0.0;
+      if (total > maxWidth) maxWidth = total;
       lineWidth = 0;
     }
 
     for (final fragment in _fragments) {
-      if (fragment is _BlockStartFragment || fragment is _BlockEndFragment) {
+      if (fragment is _BlockStartFragment) {
         flushLine();
+        final margin = fragment.appliesHorizontalMargins
+            ? fragment.style.margin.left + fragment.style.margin.right
+            : 0.0;
+        final own = fragment.paddingLeft + fragment.paddingRight + margin;
+        insets.add((
+          fragment.sourceNode,
+          math.max(0.0, (insets.isEmpty ? 0.0 : insets.last.$2) + own),
+        ));
+      } else if (fragment is _BlockEndFragment) {
+        flushLine();
+        // A first block may have skipped its start fragment, so check the owner.
+        if (insets.isNotEmpty && insets.last.$1 == fragment.sourceNode) {
+          insets.removeLast();
+        }
       } else if (fragment.type == FragmentType.lineBreak) {
         flushLine();
       } else if (fragment.type == FragmentType.text ||
diff --git a/packages/hyper_render_core/lib/src/core/render_hyper_box_fragments.dart b/packages/hyper_render_core/lib/src/core/render_hyper_box_fragments.dart
index 67a6aa0..4e27eea 100644
--- a/packages/hyper_render_core/lib/src/core/render_hyper_box_fragments.dart
+++ b/packages/hyper_render_core/lib/src/core/render_hyper_box_fragments.dart
@@ -14,6 +14,11 @@ class _BlockStartFragment extends Fragment {
   /// and the last fitting fragment gets "…" appended (CSS text-overflow: ellipsis).
   final bool truncateWithEllipsis;
 
+  /// Whether the line breaker applies this block's left and right margins.
+  /// False where something else owns them: flex and grid containers draw them
+  /// in a child widget, and floats handle them in float layout.
+  final bool appliesHorizontalMargins;
+
   _BlockStartFragment({
     required super.sourceNode,
     required super.style,
@@ -22,6 +27,7 @@ class _BlockStartFragment extends Fragment {
     this.paddingLeft = 0,
     this.paddingRight = 0,
     this.truncateWithEllipsis = false,
+    this.appliesHorizontalMargins = false,
   }) : super(type: FragmentType.text, text: '');
 }
 
diff --git a/packages/hyper_render_core/lib/src/core/render_hyper_box_layout.dart b/packages/hyper_render_core/lib/src/core/render_hyper_box_layout.dart
index c772fb0..f01f90e 100644
--- a/packages/hyper_render_core/lib/src/core/render_hyper_box_layout.dart
+++ b/packages/hyper_render_core/lib/src/core/render_hyper_box_layout.dart
@@ -235,6 +235,8 @@ extension _RenderHyperBoxLayout on RenderHyperBox {
         hasWidthConstraint ||
         hasPadding ||
         hasDecoration ||
+        style.margin.left != 0 ||
+        style.margin.right != 0 ||
         style.textOverflow == TextOverflow.ellipsis) {
       _fragments.add(_BlockStartFragment(
         sourceNode: node,
@@ -244,6 +246,7 @@ extension _RenderHyperBoxLayout on RenderHyperBox {
         paddingLeft: style.padding.left,
         paddingRight: style.padding.right,
         truncateWithEllipsis: style.textOverflow == TextOverflow.ellipsis,
+        appliesHorizontalMargins: style.float == HyperFloat.none,
       ));
     }
 
@@ -825,6 +828,8 @@ extension _RenderHyperBoxLayout on RenderHyperBox {
     // Stack to track nested block indentation
     final List leftPaddingStack = [0];
     final List rightPaddingStack = [0];
+    // The block that pushed each stack level; null for the root level.
+    final List paddingStackOwners = [null];
 
     // Track active blocks for decoration (border-left, background)
     // Tuple: (fragment, startY, leftX, rightX)
@@ -1010,9 +1015,22 @@ extension _RenderHyperBoxLayout on RenderHyperBox {
 
         currentY += fragment.marginTop + fragment.paddingTop;
 
+        // The box edges: the containing block's content edges, moved in by this
+        // block's margins. Clamped to this render box, so a negative margin can
+        // reach into a parent's padding but no margin can place a background
+        // outside the box, over the host app.
+        final margin =
+            fragment.appliesHorizontalMargins ? fragment.style.margin : null;
+        final boxLeftInset =
+            (leftPaddingStack.last + (margin?.left ?? 0)).clamp(0.0, _maxWidth);
+        final boxRightInset = (rightPaddingStack.last + (margin?.right ?? 0))
+            .clamp(0.0, _maxWidth);
+
         // ACCUMULATE padding for nested blocks
-        final newLeftPadding = leftPaddingStack.last + fragment.paddingLeft;
-        var newRightPadding = rightPaddingStack.last + fragment.paddingRight;
+        var newLeftPadding = boxLeftInset + fragment.paddingLeft;
+        var newRightPadding = boxRightInset + fragment.paddingRight;
+        // Left edge of this block's painted box. Moves if `auto` margins do.
+        var boxLeftX = boxLeftInset;
 
         // CSS width constraints on a block, resolved via the existing per-block
         // padding stack (no separate per-block width machinery needed). We pick
@@ -1089,20 +1107,34 @@ extension _RenderHyperBoxLayout on RenderHyperBox {
               math.max(0.0, _maxWidth - newLeftPadding - newRightPadding);
           target = target.clamp(0.0, avail);
 
-          // Borders don't reserve line-breaker space yet, so they are added
-          // here only for the painted box. Capped so a box never paints past
-          // its containing block.
+          // Painted width. Borders don't reserve line-breaker space yet, so
+          // they count here only.
+          final boxWidth =
+              fragment.paddingLeft + target + fragment.paddingRight + borderSum;
+
+          // `auto` margins share the free space: centred when both are auto.
+          // Right-to-left, a box with no `auto` margin anchors to the right edge.
+          if (fragment.appliesHorizontalMargins &&
+              (st.marginLeftAuto || (isRTL && !st.marginRightAuto))) {
+            final free = math.max(
+                0.0, _maxWidth - boxLeftInset - boxRightInset - boxWidth);
+            final shift =
+                st.marginLeftAuto && st.marginRightAuto ? free / 2 : free;
+            boxLeftX += shift;
+            newLeftPadding += shift;
+          }
+
+          // Capped so a box never paints past its containing block.
           if (!isWidgetBox) {
-            constrainedBoxRight = math.min(
-              _maxWidth - rightPaddingStack.last,
-              newLeftPadding + target + fragment.paddingRight + borderSum,
-            );
+            constrainedBoxRight =
+                math.min(_maxWidth - boxRightInset, boxLeftX + boxWidth);
           }
           newRightPadding = _maxWidth - newLeftPadding - target;
         }
 
         leftPaddingStack.add(newLeftPadding);
         rightPaddingStack.add(newRightPadding);
+        paddingStackOwners.add(fragment.sourceNode);
 
         leftInset = newLeftPadding;
         rightInset = newRightPadding;
@@ -1118,14 +1150,20 @@ extension _RenderHyperBoxLayout on RenderHyperBox {
         final hasBorderLeft =
             style.borderColor != null && style.borderWidth.left > 0;
         if (hasBackground || hasBorderLeft) {
-          // Calculate the edge positions (account for parent padding but not this block's)
-          final blockLeftX = leftPaddingStack.length > 1
-              ? leftPaddingStack[leftPaddingStack.length - 2]
-              : 0.0;
-          final blockRightX = constrainedBoxRight ??
-              (rightPaddingStack.length > 1
-                  ? _maxWidth - rightPaddingStack[rightPaddingStack.length - 2]
-                  : _maxWidth);
+          // The painted box: inside the margins, outside this block's padding.
+          // Flex and grid containers apply their margins in the child widget, so
+          // their canvas decoration has to match it here.
+          final widgetMargin = style.display == DisplayType.flex ||
+                  style.display == DisplayType.grid
+              ? style.margin
+              : EdgeInsets.zero;
+          final blockLeftX =
+              (boxLeftX + widgetMargin.left).clamp(0.0, _maxWidth);
+          final blockRightX = math.max(
+            blockLeftX,
+            (constrainedBoxRight ?? (_maxWidth - boxRightInset)) -
+                widgetMargin.right,
+          );
           // startY is BEFORE padding (current position includes padding, so subtract it)
           final blockStartY = currentY - fragment.paddingTop;
           activeBlocks.add((fragment, blockStartY, blockLeftX, blockRightX));
@@ -1225,6 +1263,7 @@ extension _RenderHyperBoxLayout on RenderHyperBox {
         // Pop the padding stack
         if (leftPaddingStack.length > 1) leftPaddingStack.removeLast();
         if (rightPaddingStack.length > 1) rightPaddingStack.removeLast();
+        if (paddingStackOwners.length > 1) paddingStackOwners.removeLast();
 
         leftInset = leftPaddingStack.last;
         rightInset = rightPaddingStack.last;
@@ -1233,7 +1272,17 @@ extension _RenderHyperBoxLayout on RenderHyperBox {
       }
 
       if (fragment is _FloatFragment) {
-        _layoutFloat(fragment, currentY);
+        // A float sits inside its containing block. A floated block has usually
+        // pushed its own level, which holds its own padding: use the one below.
+        final level = paddingStackOwners.last == fragment.sourceNode
+            ? leftPaddingStack.length - 2
+            : leftPaddingStack.length - 1;
+        _layoutFloat(
+          fragment,
+          currentY,
+          containerLeft: leftPaddingStack[level],
+          containerRight: _maxWidth - rightPaddingStack[level],
+        );
         return;
       }
 
@@ -1303,14 +1352,17 @@ extension _RenderHyperBoxLayout on RenderHyperBox {
         // Find the child RenderBox for this code block
         RenderBox? codeBlockChild = _findChildForFragment(fragment);
         double blockHeight = 0.0;
-        double blockWidth = _maxWidth;
+        // Inside the block's insets: it is placed at leftInset below.
+        final availableWidth =
+            math.max(0.0, _maxWidth - leftInset - rightInset);
+        double blockWidth = availableWidth;
 
         if (codeBlockChild != null) {
           if (intrinsicMode) {
-            blockHeight = codeBlockChild.getMaxIntrinsicHeight(_maxWidth);
+            blockHeight = codeBlockChild.getMaxIntrinsicHeight(availableWidth);
           } else {
             codeBlockChild.layout(
-              BoxConstraints(maxWidth: _maxWidth),
+              BoxConstraints(maxWidth: availableWidth),
               parentUsesSize: true,
             );
             blockHeight = codeBlockChild.size.height;
@@ -2267,7 +2319,12 @@ extension _RenderHyperBoxLayout on RenderHyperBox {
     return buf.toString().trim();
   }
 
-  void _layoutFloat(Fragment fragment, double currentY) {
+  void _layoutFloat(
+    Fragment fragment,
+    double currentY, {
+    required double containerLeft,
+    required double containerRight,
+  }) {
     if (fragment is! _FloatFragment) return;
     // Can't position floats without a finite container width.
     if (_maxWidth.isInfinite || _maxWidth <= 0) return;
@@ -2371,15 +2428,16 @@ extension _RenderHyperBoxLayout on RenderHyperBox {
     double floatY = currentY;
 
     if (fragment.floatDirection == HyperFloat.left) {
-      double left = 0;
+      double left = containerLeft;
 
       // Early exit: if the float is wider than the container it can never fit
       // horizontally regardless of Y position. Clamp to container width so the
       // loop below always terminates in O(1) for this degenerate case instead
       // of spending all 100 iterations advancing 1px at a time on an empty
       // float list (lowestBottom = floatY + 1 fallback).
-      if (width + rightMargin > _maxWidth) {
-        width = math.max(0.0, _maxWidth - rightMargin);
+      final containerWidth = math.max(0.0, containerRight - containerLeft);
+      if (width + rightMargin > containerWidth) {
+        width = math.max(0.0, containerWidth - rightMargin);
       }
 
       // Find available position - may need to move down if float doesn't fit
@@ -2392,7 +2450,7 @@ extension _RenderHyperBoxLayout on RenderHyperBox {
       const maxIterations = 100;
 
       while (!foundPosition && iterations < maxIterations) {
-        left = 0;
+        left = containerLeft;
 
         // Check existing left floats at current Y position
         for (final existing in _leftFloats) {
@@ -2402,7 +2460,7 @@ extension _RenderHyperBoxLayout on RenderHyperBox {
         }
 
         // Check right floats to ensure there's enough space
-        double rightEdge = _maxWidth;
+        double rightEdge = containerRight;
         for (final existing in _rightFloats) {
           if (floatY >= existing.rect.top && floatY < existing.rect.bottom) {
             rightEdge = math.min(rightEdge, existing.rect.left);
@@ -2463,11 +2521,12 @@ extension _RenderHyperBoxLayout on RenderHyperBox {
           rect: floatRect, direction: HyperFloat.left, imageSrc: imgSrc));
       _cachedAvailableWidth = null;
     } else {
-      double right = _maxWidth;
+      double right = containerRight;
 
       // Early exit: clamp oversized right float to container width.
-      if (width + leftMargin > _maxWidth) {
-        width = math.max(0.0, _maxWidth - leftMargin);
+      final containerWidth = math.max(0.0, containerRight - containerLeft);
+      if (width + leftMargin > containerWidth) {
+        width = math.max(0.0, containerWidth - leftMargin);
       }
 
       // Find available position - may need to move down if float doesn't fit
@@ -2476,7 +2535,7 @@ extension _RenderHyperBoxLayout on RenderHyperBox {
       const maxIterations = 100;
 
       while (!foundPosition && iterations < maxIterations) {
-        right = _maxWidth;
+        right = containerRight;
 
         // Check existing right floats at current Y position
         for (final existing in _rightFloats) {
@@ -2486,7 +2545,7 @@ extension _RenderHyperBoxLayout on RenderHyperBox {
         }
 
         // Check left floats to ensure there's enough space
-        double leftEdge = 0;
+        double leftEdge = containerLeft;
         for (final existing in _leftFloats) {
           if (floatY >= existing.rect.top && floatY < existing.rect.bottom) {
             leftEdge = math.max(leftEdge, existing.rect.right);
diff --git a/packages/hyper_render_core/lib/src/model/computed_style.dart b/packages/hyper_render_core/lib/src/model/computed_style.dart
index 3a88c7a..9037e61 100644
--- a/packages/hyper_render_core/lib/src/model/computed_style.dart
+++ b/packages/hyper_render_core/lib/src/model/computed_style.dart
@@ -403,6 +403,12 @@ class ComputedStyle {
   /// CSS max-width
   double? maxWidth;
 
+  /// CSS `margin-left: auto`. Takes free space on an in-flow block with a width.
+  bool marginLeftAuto;
+
+  /// CSS `margin-right: auto`. Takes free space on an in-flow block with a width.
+  bool marginRightAuto;
+
   /// CSS box-sizing. Not inherited.
   HyperBoxSizing boxSizing;
 
@@ -846,6 +852,8 @@ class ComputedStyle {
     this.insetBottom,
     this.insetLeft,
     this.boxSizing = HyperBoxSizing.contentBox,
+    this.marginLeftAuto = false,
+    this.marginRightAuto = false,
     this.float = HyperFloat.none,
     this.clear = HyperClear.none,
     this.zIndex,
@@ -1034,6 +1042,8 @@ class ComputedStyle {
     double? insetBottom,
     double? insetLeft,
     HyperBoxSizing? boxSizing,
+    bool? marginLeftAuto,
+    bool? marginRightAuto,
     HyperFloat? float,
     HyperClear? clear,
     int? zIndex,
@@ -1147,6 +1157,8 @@ class ComputedStyle {
       insetBottom: insetBottom ?? this.insetBottom,
       insetLeft: insetLeft ?? this.insetLeft,
       boxSizing: boxSizing ?? this.boxSizing,
+      marginLeftAuto: marginLeftAuto ?? this.marginLeftAuto,
+      marginRightAuto: marginRightAuto ?? this.marginRightAuto,
       float: float ?? this.float,
       clear: clear ?? this.clear,
       zIndex: zIndex ?? this.zIndex,
diff --git a/packages/hyper_render_core/lib/src/style/resolver.dart b/packages/hyper_render_core/lib/src/style/resolver.dart
index a7047c8..d78fabf 100644
--- a/packages/hyper_render_core/lib/src/style/resolver.dart
+++ b/packages/hyper_render_core/lib/src/style/resolver.dart
@@ -78,6 +78,10 @@ abstract final class _Re {
   static final combinatorSplit = RegExp(r'\s*[>+~]\s*|\s+');
   static final combinatorCapture = RegExp(r'\s*([>+~])\s*');
 
+  // Trailing `!important` on an inline declaration.
+  static final importantSuffix =
+      RegExp(r'\s*!\s*important\s*$', caseSensitive: false);
+
   // ── Pseudo-classes / pseudo-elements ─────────────────────────────────────
   static final pseudoExtract = RegExp(r'::?[a-zA-Z-]+(?:\([^()]*\))?');
   static final nthChild = RegExp(r'^:nth-child\(([^)]+)\)$');
@@ -1776,8 +1780,8 @@ class StyleResolver {
         if (_isAutoKeyword(value)) {
           style.insetTop = null;
         } else {
-          style.insetTop = _parseOffsetLength(value, style, parentFontSize) ??
-              style.insetTop;
+          style.insetTop =
+              _parseEmLength(value, style, parentFontSize) ?? style.insetTop;
         }
         break;
 
@@ -1785,8 +1789,8 @@ class StyleResolver {
         if (_isAutoKeyword(value)) {
           style.insetRight = null;
         } else {
-          style.insetRight = _parseOffsetLength(value, style, parentFontSize) ??
-              style.insetRight;
+          style.insetRight =
+              _parseEmLength(value, style, parentFontSize) ?? style.insetRight;
         }
         break;
 
@@ -1795,8 +1799,7 @@ class StyleResolver {
           style.insetBottom = null;
         } else {
           style.insetBottom =
-              _parseOffsetLength(value, style, parentFontSize) ??
-                  style.insetBottom;
+              _parseEmLength(value, style, parentFontSize) ?? style.insetBottom;
         }
         break;
 
@@ -1804,8 +1807,8 @@ class StyleResolver {
         if (_isAutoKeyword(value)) {
           style.insetLeft = null;
         } else {
-          style.insetLeft = _parseOffsetLength(value, style, parentFontSize) ??
-              style.insetLeft;
+          style.insetLeft =
+              _parseEmLength(value, style, parentFontSize) ?? style.insetLeft;
         }
         break;
 
@@ -1845,15 +1848,22 @@ class StyleResolver {
         break;
 
       case 'margin':
-        final margin = _parseEdgeInsets(value);
+        final margin =
+            _parseEdgeInsets(value, emBase: _emBase(style, parentFontSize));
         if (margin != null) {
           style.margin = margin;
+          // `auto` parses to 0 above; layout needs to know which sides it was.
+          final sides = value.trim().toLowerCase().split(_Re.whitespace);
+          style.marginRightAuto = sides.length >= 2 && sides[1] == 'auto' ||
+              sides.length == 1 && sides[0] == 'auto';
+          style.marginLeftAuto =
+              sides.length == 4 ? sides[3] == 'auto' : style.marginRightAuto;
           style.markExplicitlySet('margin');
         }
         break;
 
       case 'margin-top':
-        final val = _parseLength(value);
+        final val = _parseEmLength(value, style, parentFontSize);
         if (val != null) {
           style.margin = style.margin.copyWith(top: val);
           style.markExplicitlySet('margin');
@@ -1861,7 +1871,7 @@ class StyleResolver {
         break;
 
       case 'margin-bottom':
-        final val = _parseLength(value);
+        final val = _parseEmLength(value, style, parentFontSize);
         if (val != null) {
           style.margin = style.margin.copyWith(bottom: val);
           style.markExplicitlySet('margin');
@@ -1869,17 +1879,21 @@ class StyleResolver {
         break;
 
       case 'margin-left':
-        final val = _parseLength(value);
+        final isAuto = _isAutoKeyword(value);
+        final val = isAuto ? 0.0 : _parseEmLength(value, style, parentFontSize);
         if (val != null) {
           style.margin = style.margin.copyWith(left: val);
+          style.marginLeftAuto = isAuto;
           style.markExplicitlySet('margin');
         }
         break;
 
       case 'margin-right':
-        final val = _parseLength(value);
+        final isAuto = _isAutoKeyword(value);
+        final val = isAuto ? 0.0 : _parseEmLength(value, style, parentFontSize);
         if (val != null) {
           style.margin = style.margin.copyWith(right: val);
+          style.marginRightAuto = isAuto;
           style.markExplicitlySet('margin');
         }
         break;
@@ -3426,7 +3440,12 @@ class StyleResolver {
       final colonIdx = decl.indexOf(':');
       if (colonIdx <= 0) continue;
       final property = decl.substring(0, colonIdx).trim();
-      final value = decl.substring(colonIdx + 1).trim();
+      // An inline declaration already beats normal rules, so `!important` only
+      // needs removing here: left in, value parsers reject the whole value.
+      final value = decl
+          .substring(colonIdx + 1)
+          .replaceFirst(_Re.importantSuffix, '')
+          .trim();
       if (property.isNotEmpty && value.isNotEmpty) {
         pairs.add(MapEntry(property, value));
       }
@@ -3814,9 +3833,15 @@ class StyleResolver {
   }
 
   /// Parse CSS edge insets (margin, padding)
-  EdgeInsets? _parseEdgeInsets(String value) {
+  EdgeInsets? _parseEdgeInsets(String value, {double? emBase}) {
     final parts = value.trim().split(_Re.whitespace);
-    final values = parts.map((p) => _parseLength(p) ?? 0.0).toList();
+    final values = parts
+        .map((p) =>
+            (emBase == null
+                ? _parseLength(p)
+                : _parseLengthWithContext(p, parentFontSize: emBase)) ??
+            0.0)
+        .toList();
 
     switch (values.length) {
       case 1:
@@ -3894,19 +3919,21 @@ class StyleResolver {
 
   bool _isAutoKeyword(String value) => value.trim().toLowerCase() == 'auto';
 
-  /// A `top`/`right`/`bottom`/`left` length. `em` is this element's own font
-  /// size: font-size is hoisted ahead of other declarations, so it is already
-  /// final here if the element sets it, and otherwise equals the parent's.
-  double? _parseOffsetLength(
+  /// What `em` means on this element: its own font size. font-size is hoisted
+  /// ahead of other declarations, so it is already final here if the element
+  /// sets it, and otherwise equals the parent's.
+  double? _emBase(ComputedStyle style, double? parentFontSize) =>
+      style.isExplicitlySet('font-size') ? style.fontSize : parentFontSize;
+
+  /// A length whose `em` is this element's font size, unlike [_parseLength].
+  double? _parseEmLength(
     String value,
     ComputedStyle style,
     double? parentFontSize,
   ) =>
       _parseLengthWithContext(
         value,
-        parentFontSize: style.isExplicitlySet('font-size')
-            ? style.fontSize
-            : parentFontSize,
+        parentFontSize: _emBase(style, parentFontSize),
       );
 
   double? _parseLength(String value) {
diff --git a/packages/hyper_render_core/test/performance_monitor_test.dart b/packages/hyper_render_core/test/performance_monitor_test.dart
index b51ac45..d5a51df 100644
--- a/packages/hyper_render_core/test/performance_monitor_test.dart
+++ b/packages/hyper_render_core/test/performance_monitor_test.dart
@@ -362,11 +362,10 @@ void main() {
 
       for (final phase in ['parse', 'style', 'layout', 'paint']) {
         monitor.startPhase(phase);
-        // Simulate work
-        for (var i = 0; i < 100; i++) {
-          // ignore: unused_local_variable
-          final x = i * 2;
-        }
+        // Spin past a clock tick: a fixed loop could finish within one, which
+        // failed CI on fast runners with a recorded time of 0.
+        final work = Stopwatch()..start();
+        while (work.elapsedMicroseconds < 5) {}
         monitor.endPhase(phase);
       }
 
diff --git a/test/block_margin_test.dart b/test/block_margin_test.dart
new file mode 100644
index 0000000..f008f7b
--- /dev/null
+++ b/test/block_margin_test.dart
@@ -0,0 +1,442 @@
+// Horizontal margins on in-flow canvas blocks, measured through fragment
+// geometry and painted pixels.
+
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:hyper_render/hyper_render.dart';
+
+import 'support/geometry_probe.dart';
+
+final _word = 'W' * 60;
+const _bg = 'background:$probeRed;';
+
+Future _box(WidgetTester tester, String style) async {
+  await pumpHtml(tester, '
A
'); + return (await capture(tester)).extent(isRed)!; +} + +void main() { + group('lengths', () { + testWidgets('margin-left and margin-right inset the box and its text', + (tester) async { + await pumpHtml( + tester, + '
AAA' + '
', + ); + expect(fragmentTopLeft(tester, 'AAA').dx, 34); + final red = (await capture(tester)).extent(isRed)!; + expect(red.left, 30); + expect(red.right, 349); + }); + + testWidgets('the shorthand works', (tester) async { + final red = await _box(tester, 'margin:0 40px'); + expect(red.left, 40); + expect(red.right, 359); + }); + + testWidgets('margins narrow the line', (tester) async { + await pumpHtml(tester, '
$_word
'); + expect(firstLineChars(tester), 25); + await pumpHtml(tester, '
$_word
'); + expect(firstLineChars(tester), 20); + }); + + testWidgets('a first block with only a margin keeps it', (tester) async { + await pumpHtml(tester, '
AAA
'); + expect(fragmentTopLeft(tester, 'AAA').dx, 30); + }); + + testWidgets('margins add up when nested', (tester) async { + await pumpHtml( + tester, + '
AAA
' + '
', + ); + expect(fragmentTopLeft(tester, 'AAA').dx, 30); + }); + + testWidgets('a margin is not inherited, and later text returns to the edge', + (tester) async { + await pumpHtml( + tester, + '

AAA

BBB

', + ); + expect(fragmentTopLeft(tester, 'AAA').dx, 30); + expect(fragmentTopLeft(tester, 'BBB').dx, 0); + }); + + testWidgets('a width and a margin combine', (tester) async { + final red = await _box(tester, 'width:100px;margin-left:30px'); + expect(red.left, 30); + expect(red.right, 137); + }); + + testWidgets('% width still resolves against the container', (tester) async { + await pumpHtml( + tester, + '
$_word
', + ); + expect(firstLineChars(tester), 12); // 200px, not 150px + }); + + testWidgets('a margin wider than the container does not throw', + (tester) async { + await pumpHtml( + tester, + '
AAA

BBB

', + ); + expect(tester.takeException(), isNull); + expect(fragmentTopLeft(tester, 'BBB').dx, 0); + }); + }); + + group('auto', () { + testWidgets('margin:0 auto centres a block with a width', (tester) async { + final red = await _box(tester, 'width:200px;margin:0 auto'); + // Box is 208 wide with its padding, leaving 192: 96 on each side. + expect(red.left, 96); + expect(red.right, 303); + }); + + testWidgets('it centres the text with it', (tester) async { + await pumpHtml( + tester, + '
AAA
', + ); + expect(fragmentTopLeft(tester, 'AAA').dx, 100); + }); + + testWidgets('the common max-width layout centres, in stylesheet form', + (tester) async { + await pumpHtml( + tester, + '
A
', + ); + final red = (await capture(tester)).extent(isRed)!; + expect(red.left, 100); + expect(red.right, 299); + }); + + testWidgets('max-width wider than the container fills it', (tester) async { + final red = await _box(tester, 'max-width:900px;margin:0 auto'); + expect(red.left, 0); + expect(red.right, 399); + }); + + testWidgets('no width: auto is 0', (tester) async { + final red = await _box(tester, 'margin:0 auto'); + expect(red.left, 0); + expect(red.right, 399); + }); + + testWidgets('margin-left:auto pushes the box right', (tester) async { + final red = await _box(tester, 'width:100px;margin-left:auto'); + expect(red.right, 399); + expect(red.left, 292); + }); + + testWidgets('margin-right:auto leaves it at the left', (tester) async { + final red = await _box(tester, 'width:100px;margin-right:auto'); + expect(red.left, 0); + expect(red.right, 107); + }); + + testWidgets('auto with a fixed opposite margin takes what is left', + (tester) async { + final red = + await _box(tester, 'width:100px;margin-left:auto;margin-right:20px'); + expect(red.right, 379); + }); + + testWidgets('a later length replaces auto', (tester) async { + final red = await _box( + tester, 'width:100px;margin:0 auto;margin-left:10px;margin-right:0'); + expect(red.left, 10); + }); + + testWidgets('three- and four-value shorthands read auto', (tester) async { + expect((await _box(tester, 'width:192px;margin:0 auto 4px')).left, 100); + expect( + (await _box(tester, 'width:192px;margin:0 auto 4px auto')).left, 100); + }); + + testWidgets('it centres inside a parent with padding', (tester) async { + await pumpHtml( + tester, + '
' + '
A
', + ); + final red = (await capture(tester)).extent(isRed)!; + // Parent content 300 wide from x=50; box 108, so 96 on each side. + expect(red.left, 146); + expect(red.right, 253); + }); + }); + + group('negative margins', () { + testWidgets('can reach into the parent padding', (tester) async { + await pumpHtml( + tester, + '
AAA
' + '
', + ); + expect(fragmentTopLeft(tester, 'AAA').dx, 0); + }); + + testWidgets('cannot leave the render box', (tester) async { + await pumpHtml( + tester, + '
AAA
', + ); + expect(fragmentTopLeft(tester, 'AAA').dx, 4); + final red = (await capture(tester)).extent(isRed)!; + expect(red.left, 0); + expect(red.right, 399); + }); + }); + + group('other paths are unchanged', () { + testWidgets('a float uses its own margin handling', (tester) async { + await pumpHtml( + tester, + '
' + '

BBB

', + ); + expect(fragmentTopLeft(tester, 'BBB').dx, 120); + }); + + testWidgets('a flex container is not inset twice', (tester) async { + await pumpHtml( + tester, + '
' + '
A
', + ); + expect((await capture(tester)).extent(isRed)!.left, 30); + }); + + testWidgets('position:relative moves a margined box', (tester) async { + final red = await _box( + tester, 'width:100px;margin-left:30px;position:relative;left:10px'); + expect(red.left, 40); + }); + + testWidgets('box-sizing keeps margins outside the box', (tester) async { + final red = await _box( + tester, 'width:100px;margin-left:30px;box-sizing:border-box'); + expect(red.left, 30); + expect(red.right, 129); + }); + }); + + group('code blocks', () { + Finder preChild() => find.byType(CodeBlockWidget); + + testWidgets('a margin narrows the block instead of pushing it out', + (tester) async { + await pumpHtml(tester, '
x

Z

'); + final rect = tester.getRect(preChild()); + expect(rect.left, 40); + expect(rect.right, lessThanOrEqualTo(360)); + }); + + testWidgets('a huge margin stays inside the box', (tester) async { + await pumpHtml(tester, '
x

Z

'); + expect(tester.takeException(), isNull); + expect(tester.getRect(preChild()).right, lessThanOrEqualTo(400)); + }); + }); + + group('floats inside a block with margins', () { + const image = 'float:%S;width:50px;height:50px'; + + // An empty float paints nothing of its own, so read its laid-out position. + double floatX(WidgetTester tester) => hyperBoxes(tester) + .first + .debugFragments() + .firstWhere((f) => f['type'] == 'atomic')['offsetX'] as double; + + testWidgets('a left float sits at the container edge, with text beside it', + (tester) async { + await pumpHtml( + tester, + '
' + '
' + '
AAA
', + ); + expect(floatX(tester), 100); + // Beside the float: container edge + 50 + the default 8px float margin. + expect(fragmentTopLeft(tester, 'AAA').dx, 158); + }); + + testWidgets('a right float sits at the container edge', (tester) async { + await pumpHtml( + tester, + '
' + '
AAA
', + ); + // Right edge 300, minus 50 wide. + expect(floatX(tester), 250); + expect(fragmentTopLeft(tester, 'AAA').dx, 100); + }); + + testWidgets('text wraps beside the float inside the container', + (tester) async { + await pumpHtml( + tester, + '
' + '
' + 'AAA
', + ); + expect(fragmentTopLeft(tester, 'AAA').dx, 158); + }); + + testWidgets('a float clears the parent padding, not its own', + (tester) async { + await pumpHtml( + tester, + '
' + 'AAA

Z

', + ); + expect(floatX(tester), 30); + }); + }); + + group('inline !important', () { + for (final style in [ + 'width:200px;margin:auto !important', + 'width:200px;margin:0 auto!important', + 'width:200px;margin:0 auto !important', + 'width:200px;margin-left:auto !important;margin-right:auto !important', + ]) { + testWidgets(style, (tester) async { + final red = await _box(tester, style); + expect(red.left, 96); + }); + } + + testWidgets('other properties accept it too', (tester) async { + final red = await _box(tester, 'margin-left:30px !important'); + expect(red.left, 30); + }); + }); + + group('em margins', () { + testWidgets('use the element font size, in any order', (tester) async { + for (final style in [ + 'font-size:32px;margin-left:2em', + 'margin-left:2em;font-size:32px', + 'font-size:32px;margin:0 0 0 2em', + ]) { + await pumpHtml(tester, '
AAA
'); + expect(fragmentTopLeft(tester, 'AAA').dx, 64, reason: style); + } + }); + + testWidgets('a vertical em margin does too', (tester) async { + await pumpHtml(tester, '

Z

AAA
'); + final plain = fragmentTopLeft(tester, 'AAA').dy; + await pumpHtml(tester, + '

Z

AAA
'); + expect( + fragmentTopLeft(tester, 'AAA').dy, greaterThanOrEqualTo(plain + 16)); + }); + }); + + group('right-to-left', () { + Future rtlBox(WidgetTester tester, String style) async { + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: RepaintBoundary( + key: probeBoundaryKey, + child: SizedBox( + width: 400, + height: 200, + child: HyperViewer( + html: '
A
', + mode: HyperRenderMode.sync, + textDirection: TextDirection.rtl, + ), + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + return (await capture(tester)).extent(isRed)!; + } + + setUp(() {}); + + testWidgets('a box with a width anchors to the right edge', (tester) async { + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetDevicePixelRatio); + final red = await rtlBox(tester, 'width:100px;margin-right:30px'); + expect(red.right, 369); + expect(red.left, 262); + }); + + testWidgets('margin-right:auto pushes it left', (tester) async { + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetDevicePixelRatio); + final red = await rtlBox(tester, 'width:100px;margin-right:auto'); + expect(red.left, 0); + }); + + testWidgets('margin:0 auto still centres', (tester) async { + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetDevicePixelRatio); + final red = await rtlBox(tester, 'width:192px;margin:0 auto'); + expect(red.left, 100); + }); + }); + + group('flex container background', () { + testWidgets('stays inside the margins', (tester) async { + await pumpHtml( + tester, + '

Z

A
', + ); + final red = (await capture(tester)).extent(isRed)!; + expect(red.left, 30); + expect(red.right, 369); + }); + }); + + group('intrinsic width', () { + testWidgets('leaves room for margins and padding', (tester) async { + Future lines(String style) async { + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: RepaintBoundary( + key: probeBoundaryKey, + child: IntrinsicWidth( + child: HyperViewer( + html: '
Hello world
', + mode: HyperRenderMode.sync, + selectable: false, + ), + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + return lineFragments(tester).length; + } + + final plain = await lines(''); + expect(await lines('margin-left:40px'), plain); + expect(await lines('padding:0 25px'), plain); + }); + }); +}