diff --git a/CHANGELOG.md b/CHANGELOG.md
index 475de847..20401bee 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,21 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.
---
+## [Unreleased]
+
+### Fixed
+
+- **`max-h-*` and `max-w-*` now apply to an `h-full` element.** Both were discarded, by the same mechanism in two places: the cap arrived as a `ConstrainedBox` whose additional constraint `BoxConstraints.enforce` clamps into the incoming range, and the incoming range was already tight. `h-full max-h-[120px]` under a `ConstrainedBox(maxHeight: 400)` rendered 400 and now renders 120; `w-1/2 h-full max-w-[100px]` in a 300 pixel parent rendered 150 and now renders 100. A TIGHT parent still wins over `max-h-*`, which is correct rather than the same bug: a tight constraint is the parent stating an exact size.
+- **`h-full` no longer throws under an `IntrinsicHeight`.** It resolved through a `LayoutBuilder`, which cannot answer an intrinsic query, so any `IntrinsicHeight` / `IntrinsicWidth` above it asserted `LayoutBuilder does not support returning intrinsic dimensions`. The limitation was documented on five surfaces with an escape hatch ("use explicit `h-*` instead") rather than fixed. `h-full` is now the `WindFullHeightBox` render object, which answers intrinsics by forwarding to its child, so it renders under an `IntrinsicHeight`, in a `Table` cell and in an `items-stretch` grid cell, and matches the tallest sibling rather than reporting the screen height. `grid` is the one remaining `LayoutBuilder` path.
+
+### Changed
+
+- **`h-full` resolves at the render layer instead of through a `LayoutBuilder`.** The question it asks ("is the incoming height bounded") is only answerable during layout, and a `LayoutBuilder` was the widget-layer way to ask it; a `LayoutBuilder` also defers its whole subtree into a second layout pass. A consumer measured 1056 of them in one eight-scroll session against 258 widget builds, one per element carrying the class, re-run every frame. `WindFullHeightBox` reads `constraints` directly and needs neither. Behaviour is otherwise unchanged, pinned by twelve characterisation tests written against the old implementation first.
+
+### Quality
+
+- Twenty tests for `WindFullHeightBox`, none skipped, covering: bounded and unbounded, with and without a width factor, `max-w-*` and `max-h-*`, the outer box's own reported size, in-place updates through all four setters (including a screen-size change, which is what a rotation is), a childless element, and the dry-layout contract agreeing with the size actually laid out. Line coverage 94.5% to 95.2%. Every line of the new file is covered, including both null-child branches: a childless `WDiv` carrying only `h-full` builds no core structure and reaches the box with a null child, so an earlier `coverage:ignore` on those lines rested on a false premise.
+
## [1.5.1] - 2026-09-07
### Fixed
diff --git a/doc/layout/flexbox.md b/doc/layout/flexbox.md
index a49d5d58..80d48217 100644
--- a/doc/layout/flexbox.md
+++ b/doc/layout/flexbox.md
@@ -212,7 +212,7 @@ WDiv(className: 'flex items-center h-20')
> **Column cross-axis stretch (default AND explicit `items-stretch`).** A `flex flex-col` with no explicit `items-*` token, OR with an explicit `items-stretch`, stretches each `WDiv`, `WAnchor` (any child), and `WButton` child that does not control its own width to the column width, matching CSS `align-items: stretch`. Explicit `items-stretch` therefore equalizes child widths (every eligible child fills the column), closing the asymmetry with `grid ... items-stretch`. For `WAnchor`: when the anchor wraps a `WDiv`, the inner `WDiv`'s className decides (so `WAnchor > WDiv(w-32)` keeps 128 px; a `WAnchor > WDiv` with a self-flex token is excluded just as a direct self-flexing `WDiv` is); when the anchor wraps a `WText` or raw widget, the anchor stretches by policy so its tap surface fills the column. Left untouched: children with an explicit width (`w-*` / `min-w-*` / `max-w-*` / `w-full`, in any state/breakpoint variant), children that self-wrap in `Expanded`/`Flexible` (`grow`, `flex-grow`, `flex-auto`, `flex-initial`, `shrink`, `flex-shrink`, `flex-N`), `basis-*` children, absolute children, bare `WText` leaves, and raw Flutter widgets. `shrink-0` / `flex-none` children still stretch on the cross axis (`flex-shrink` is main-axis only, matching CSS). Add `items-start` / `items-center` / `items-end` to turn stretch off and let children size to content. Rows are never auto-stretched on the cross axis. When the column itself sits in an unbounded-width context (a bare `Row` slot, `UnconstrainedBox`, horizontal scroll), the stretch safely falls back to content-sized children instead of forcing an infinite width.
-> **Layout stability: wind's flex is intrinsic-safe.** Flutter's `IntrinsicHeight` and `IntrinsicWidth` perform an intrinsic-dimension pass that reads child sizes mid-layout, and a `LayoutBuilder` on that path asserts `LayoutBuilder does not support returning intrinsic dimensions`. Wind's flex uses NO `LayoutBuilder`: column cross-axis stretch is a real render object (`WindCrossStretch`), and `basis-*` resolves against the flex's own extent via a `WindMainExtentProvider` (see `lib/src/widgets/w_div.dart` and `wind_equal_height_row.dart`). So a `flex flex-col` (with or without `basis-*`) renders correctly inside an `items-stretch` grid cell, under an `IntrinsicHeight`/`IntrinsicWidth`, or in a `Table` cell without asserting. For a connector, rail, or divider that must fill the cross axis to match the tallest sibling, prefer a `Stack` with a `Positioned(top: 0, bottom: 0)` line, or use wind's own `items-stretch` column (also intrinsic-free and animation-safe).
+> **Layout stability: wind's flex is intrinsic-safe.** Flutter's `IntrinsicHeight` and `IntrinsicWidth` perform an intrinsic-dimension pass that reads child sizes mid-layout, and a `LayoutBuilder` on that path asserts `LayoutBuilder does not support returning intrinsic dimensions`. Wind's flex uses NO `LayoutBuilder`, and neither does `h-full` since it became the `WindFullHeightBox` render object: column cross-axis stretch is a real render object (`WindCrossStretch`), and `basis-*` resolves against the flex's own extent via a `WindMainExtentProvider` (see `lib/src/widgets/w_div.dart` and `wind_equal_height_row.dart`). So a `flex flex-col` (with or without `basis-*`) renders correctly inside an `items-stretch` grid cell, under an `IntrinsicHeight`/`IntrinsicWidth`, or in a `Table` cell without asserting. For a connector, rail, or divider that must fill the cross axis to match the tallest sibling, prefer a `Stack` with a `Positioned(top: 0, bottom: 0)` line, or use wind's own `items-stretch` column (also intrinsic-free and animation-safe).
>
> ```dart
> // Safe connector pattern: Stack + Positioned, no IntrinsicHeight
diff --git a/doc/layout/grid.md b/doc/layout/grid.md
index edb86ffa..27df0bf2 100644
--- a/doc/layout/grid.md
+++ b/doc/layout/grid.md
@@ -79,7 +79,7 @@ WDiv(
)
```
-The equal-height rows are laid out for real (each cell is measured with a loose height, then re-laid to at least the row's tallest via a **min** height, never a tight squeeze), NOT via `IntrinsicHeight`, so cells whose content is itself a `flex flex-col`, or that use `h-full` / `basis-*` (which carry a `LayoutBuilder`), stretch correctly instead of asserting `LayoutBuilder does not support returning intrinsic dimensions`. Because a cell is never forced below its own content height, a stretched cell also produces no residual `RenderFlex overflowed` warning (#141).
+The equal-height rows are laid out for real (each cell is measured with a loose height, then re-laid to at least the row's tallest via a **min** height, never a tight squeeze), NOT via `IntrinsicHeight`, so cells whose content is itself a `flex flex-col`, or that use `h-full` / `basis-*` (neither of which carries a `LayoutBuilder` any more), stretch correctly instead of asserting `LayoutBuilder does not support returning intrinsic dimensions`. Because a cell is never forced below its own content height, a stretched cell also produces no residual `RenderFlex overflowed` warning (#141).
## Responsive
diff --git a/doc/layout/sizing.md b/doc/layout/sizing.md
index 56eceb9f..41d39f3a 100644
--- a/doc/layout/sizing.md
+++ b/doc/layout/sizing.md
@@ -177,30 +177,28 @@ Wrapping Wind content in `IntrinsicHeight` or `IntrinsicWidth` (or placing it in
LayoutBuilder does not support returning intrinsic dimensions.
```
-**Why.** To resolve some sizes against the parent's real constraints, Wind introduces a `LayoutBuilder` (see `lib/src/widgets/w_div.dart`): `h-full` adds one around the cell only when the incoming height is unbounded, and a flex `basis-*` adds a single one around the surrounding flex when any direct child uses `basis-*`. `LayoutBuilder` runs during the layout phase, not the intrinsic-sizing phase, so an intrinsic-dimension query that passes through one of those `LayoutBuilder` paths asserts. This is a fundamental Flutter constraint (`LayoutBuilder` genuinely cannot answer intrinsics), not a Wind bug. Wind content that hits none of Wind's `LayoutBuilder` paths (`h-full` in an unbounded height, `basis-*`, or the column cross-axis stretch above) carries no `LayoutBuilder` and is safe to wrap.
+**Why.** To resolve a size against the parent's real constraints, Wind used to introduce a `LayoutBuilder`, and a `LayoutBuilder` runs during the layout phase rather than the intrinsic-sizing phase, so any intrinsic query passing through one asserts. That is a Flutter constraint, not a Wind bug.
-**What triggers it.** A `WDiv` (or any W-widget) whose `className` resolves `h-full` or a flex `basis-*`, anywhere inside the subtree you wrap in `IntrinsicHeight` / `IntrinsicWidth`. A `Row` of cards that you try to equalize with `IntrinsicHeight` is the common case.
+**What still triggers it: `grid` only.** `grid-cols-*` composes a `Wrap` inside a `LayoutBuilder` (it needs the available width to compute a column width), so a `grid` anywhere inside the subtree you wrap in `IntrinsicHeight` / `IntrinsicWidth` still asserts.
-**Escape hatches.**
+**What no longer does.** `h-full` and flex `basis-*` are both intrinsic-safe now. `h-full` resolves through the `WindFullHeightBox` render object, `basis-*` through `WindMainExtentProvider`, and the column cross-axis stretch through `WindCrossStretch`; a render object answers intrinsic queries, so all three render under an `IntrinsicHeight`, in a `Table` cell, or in an `items-stretch` grid cell without throwing. If you carry an escape hatch for `h-full` from an earlier version, you can drop it.
-- Prefer explicit sizing: give the cells a fixed `h-*` (or `size-*`) instead of `h-full` + `IntrinsicHeight`.
-- Do not wrap Wind content that uses `h-full` / `basis-*` in `IntrinsicHeight` / `IntrinsicWidth`.
+**Escape hatches, for the `grid` case that remains.**
+
+- Prefer explicit sizing: give the cells a fixed `h-*` (or `size-*`) instead of a `grid` + `IntrinsicHeight`.
- For an equal-height row, use a `Stack` with a `Positioned(top: 0, bottom: 0)` element for the part that must fill, or reserve equal content so natural heights already match.
-- Wind's own column cross-axis stretch (`items-stretch`, the `flex flex-col` default) equalizes width WITHOUT you wrapping it in `IntrinsicHeight`. It uses a `LayoutBuilder` + `SizedBox(width: double.infinity)` internally (gated on a bounded width), so treat it as a REPLACEMENT for `IntrinsicHeight`, not something to nest inside one.
+- Wind's own `items-stretch` grid equalizes row heights with real layout rather than `IntrinsicHeight`, so reach for it INSTEAD of wrapping.
```dart
-// Throws if a card resolves h-full / basis-* internally:
+// Throws: the wrapped subtree contains a grid.
IntrinsicHeight(
- child: WDiv(className: 'flex flex-row', children: cards),
+ child: WDiv(className: 'grid grid-cols-2 gap-4', children: cards),
)
-// Safe: explicit height on each cell, no IntrinsicHeight needed.
+// Safe: items-stretch equalizes the row heights with real layout.
WDiv(
- className: 'flex flex-row gap-4',
- children: [
- WDiv(className: 'h-40 ...', child: card1),
- WDiv(className: 'h-40 ...', child: card2),
- ],
+ className: 'grid grid-cols-2 gap-4 items-stretch',
+ children: cards,
)
```
diff --git a/lib/src/widgets/w_div.dart b/lib/src/widgets/w_div.dart
index b6f1bac8..fa6e9a77 100644
--- a/lib/src/widgets/w_div.dart
+++ b/lib/src/widgets/w_div.dart
@@ -5,6 +5,7 @@ import '../parser/wind_style.dart';
import '../utils/wind_logger.dart';
import '../utils/wind_perf_counters.dart';
import 'wind_animation_wrapper.dart';
+import 'wind_full_height.dart';
import '../state/wind_anchor_state_provider.dart';
import '../state/wind_flex_overflow_scope.dart';
import '../state/wind_min_width_scroll_scope.dart';
@@ -624,7 +625,7 @@ class WDiv extends StatelessWidget {
/// Builds the final `Row`/`Column` from `basis-*`-resolved children, applying
/// the column smart cross-axis stretch or the row `Flexible` shrink wrap.
/// Split out of [_buildFlexStructure] so it can run either directly or inside
- /// the `basis-*` `LayoutBuilder`.
+ /// the `basis-*` extent provider.
Widget _composeFlex({
required WindStyle styles,
required bool isColumn,
@@ -850,7 +851,7 @@ class WDiv extends StatelessWidget {
}
/// Whether any direct flex child carries a `basis-*` token. Cheap pre-check
- /// (substring) so the common no-basis case skips the LayoutBuilder wrap.
+ /// (substring) so the common no-basis case skips the extent-provider wrap.
static bool _anyChildHasBasis(List children) {
for (final child in children) {
final className = _extractChildClassName(child);
@@ -1280,9 +1281,11 @@ class WDiv extends StatelessWidget {
/// cells so columns stay aligned.
///
/// Because the row uses real layout rather than the intrinsic protocol, cells
- /// whose content is a `flex flex-col`, or that use `h-full` / `basis-*` (all
- /// of which carry a `LayoutBuilder`), stretch correctly instead of asserting
- /// `LayoutBuilder does not support returning intrinsic dimensions` (#139).
+ /// whose content is a `flex flex-col`, or that use `h-full` / `basis-*`,
+ /// stretch correctly instead of asserting `LayoutBuilder does not support
+ /// returning intrinsic dimensions` (#139). None of those three carries a
+ /// `LayoutBuilder` any more, so they would survive the intrinsic protocol
+ /// too; real layout is still the cheaper path and is what this builds.
Widget _buildStretchGrid(
int cols,
double gapX,
@@ -1312,9 +1315,9 @@ class WDiv extends StatelessWidget {
}
// WindEqualHeightRow measures each cell with a real (loose-height) layout
// and re-lays it to at least the row max via a MIN height, instead of the
- // intrinsic query IntrinsicHeight would run. A `flex flex-col` cell (which
- // carries a LayoutBuilder) can then be stretched without the "LayoutBuilder
- // does not support returning intrinsic dimensions" assert (#139), and the
+ // intrinsic query IntrinsicHeight would run. That kept a `flex flex-col`
+ // cell clear of the "LayoutBuilder does not support returning intrinsic
+ // dimensions" assert (#139) back when it carried one, and the
// min (never tight) height leaves no residual RenderFlex overflow (#141).
rows.add(WindEqualHeightRow(spacing: gapX, children: rowChildren));
}
@@ -1636,11 +1639,14 @@ class WDiv extends StatelessWidget {
// markNeedsLayout (e.g. textScaler change), it causes a debug assertion:
// _debugRelayoutBoundaryAlreadyMarkedNeedsLayout() is not true
//
+ // No path below carries one any more: `h-full` is the `WindFullHeightBox`
+ // render object. `grid` is the last LayoutBuilder in the widget.
+ //
// Strategy:
// - w-full: SizedBox(width: infinity), no LayoutBuilder needed
// - w-full + max-w-*: ConstrainedBox + SizedBox, no LayoutBuilder needed
// - w-1/2, w-1/3 etc: FractionallySizedBox, no LayoutBuilder needed
- // - h-full: LayoutBuilder only when vertical axis is unbounded
+ // - h-full: WindFullHeightBox, a render object, on both axes
if (styles.widthFactor != null || styles.heightFactor != null) {
final innerChild = widgetToBuild;
@@ -1715,34 +1721,22 @@ class WDiv extends StatelessWidget {
}
} else if (styles.widthFactor == null) {
// Height-only fractional sizing (h-full, h-1/2, etc.)
- // Vertical axis is often unbounded (ScrollView/Column), so we need
- // LayoutBuilder only for h-full in unbounded contexts.
+ // The vertical axis is often unbounded (ScrollView/Column), which only
+ // `h-full` has to resolve against; a fraction of an unbounded height is
+ // meaningless, so `h-1/2` and friends stay a plain FractionallySizedBox.
if (isFullHeight) {
- // h-full needs LayoutBuilder to handle unbounded vertical axis
- widgetToBuild = LayoutBuilder(
- builder: (context, constraints) {
- if (!constraints.hasBoundedHeight) {
- final screenHeight = MediaQuery.of(context).size.height;
- Widget result = SizedBox(
- height: screenHeight,
- child: innerChild,
- );
- if (hasMaxHeightConstraint) {
- result = ConstrainedBox(
- constraints: BoxConstraints(
- maxHeight: styles.constraints!.maxHeight,
- ),
- child: result,
- );
- }
- return result;
- }
- // Bounded context: use FractionallySizedBox
- return FractionallySizedBox(
- heightFactor: 1.0,
- child: innerChild,
- );
- },
+ // h-full resolves at the render layer, no LayoutBuilder.
+ //
+ // The question is only answerable during layout ("is the incoming
+ // height bounded"), and asking it with a LayoutBuilder defers this
+ // whole subtree into a second layout pass. A consumer measured 1056
+ // of them in one eight-scroll session against 258 widget builds.
+ logger.wrapWith("WindFullHeightBox", "h-full");
+ widgetToBuild = WindFullHeightBox(
+ fallbackHeight: MediaQuery.of(context).size.height,
+ maxHeight:
+ hasMaxHeightConstraint ? styles.constraints!.maxHeight : null,
+ child: innerChild,
);
} else {
// h-1/2, h-1/3, etc: FractionallySizedBox (no LayoutBuilder)
@@ -1762,49 +1756,20 @@ class WDiv extends StatelessWidget {
}
} else {
// Both width and height factors (e.g., w-full h-full, w-1/2 h-1/2)
- // Use LayoutBuilder only when needed for unbounded axis
- final bool needsLayoutBuilder = isFullHeight; // h-full may be unbounded
- if (needsLayoutBuilder) {
- widgetToBuild = LayoutBuilder(
- builder: (context, constraints) {
- final bool heightUnbounded = !constraints.hasBoundedHeight;
- final double? effectiveHeight = heightUnbounded
- ? MediaQuery.of(context).size.height *
- (styles.heightFactor ?? 1.0)
- : null;
-
- Widget result;
- if (effectiveHeight != null) {
- // Unbounded height: use calculated size
- result = SizedBox(
- width: isFullWidth ? double.infinity : null,
- height: effectiveHeight,
- child: innerChild,
- );
- } else {
- // Bounded context: FractionallySizedBox handles both axes
- result = FractionallySizedBox(
- widthFactor: styles.widthFactor,
- heightFactor: styles.heightFactor,
- child: innerChild,
- );
- }
-
- if (hasMaxWidthConstraint || hasMaxHeightConstraint) {
- result = ConstrainedBox(
- constraints: BoxConstraints(
- maxWidth: hasMaxWidthConstraint
- ? styles.constraints!.maxWidth
- : double.infinity,
- maxHeight: hasMaxHeightConstraint
- ? styles.constraints!.maxHeight
- : double.infinity,
- ),
- child: result,
- );
- }
- return result;
- },
+ // `h-full` carries the width factor into the same render-layer box;
+ // everything else is a plain FractionallySizedBox on both axes.
+ if (isFullHeight) {
+ // Both axes, height full: the same render-layer box as the
+ // height-only path, carrying the width factor too.
+ logger.wrapWith("WindFullHeightBox", "w+h-full");
+ widgetToBuild = WindFullHeightBox(
+ fallbackHeight: MediaQuery.of(context).size.height,
+ widthFactor: styles.widthFactor,
+ maxWidth:
+ hasMaxWidthConstraint ? styles.constraints!.maxWidth : null,
+ maxHeight:
+ hasMaxHeightConstraint ? styles.constraints!.maxHeight : null,
+ child: innerChild,
);
} else {
// Both fractional, neither h-full: FractionallySizedBox handles it
diff --git a/lib/src/widgets/wind_equal_height_row.dart b/lib/src/widgets/wind_equal_height_row.dart
index cddd3077..aa77e85e 100644
--- a/lib/src/widgets/wind_equal_height_row.dart
+++ b/lib/src/widgets/wind_equal_height_row.dart
@@ -5,18 +5,20 @@ import 'package:flutter/widgets.dart';
/// REAL two-pass layout rather than the intrinsic-sizing protocol.
///
/// This is the intrinsic-free replacement for `IntrinsicHeight` + a
-/// `Row(crossAxisAlignment: stretch)` in the grid `items-stretch` path. Wind
-/// cell content frequently contains a `LayoutBuilder` (flex cross-axis stretch,
-/// `h-full`, `basis-*`), and `LayoutBuilder` cannot answer intrinsic or
-/// dry-layout queries, so `IntrinsicHeight` asserts `LayoutBuilder does not
-/// support returning intrinsic dimensions` the moment it has to stretch an
-/// unequal cell (issue #139). This widget instead lays each child out for real
-/// with a loose height to measure it, then lays it out again to the row's max
-/// height via a MIN constraint (never a tight one). Real layout is exactly what
-/// `LayoutBuilder` supports, so a `flex flex-col` cell stretches without
-/// asserting; and because a cell is never forced BELOW its own content height, a
-/// stretched cell leaves no residual `RenderFlex overflowed` warning the way a
-/// tight re-lay could on fractional (sub-pixel) content (issue #141).
+/// `Row(crossAxisAlignment: stretch)` in the grid `items-stretch` path.
+/// `IntrinsicHeight` measures through the intrinsic protocol, which a
+/// `LayoutBuilder` cannot answer, so it asserts `LayoutBuilder does not support
+/// returning intrinsic dimensions` the moment it has to stretch a cell carrying
+/// one (issue #139). Flex cross-axis stretch, `h-full` and `basis-*` each did
+/// when that was filed and are render objects now, but a nested `grid` still
+/// does, and a cell subtree is arbitrary caller content either way. This widget
+/// instead lays each child out for real with a loose height to measure it, then
+/// lays it out again to the row's max height via a MIN constraint (never a tight
+/// one). Real layout is exactly what `LayoutBuilder` supports, so a `flex
+/// flex-col` cell stretches without asserting; and because a cell is never
+/// forced BELOW its own content height, a stretched cell leaves no residual
+/// `RenderFlex overflowed` warning the way a tight re-lay could on fractional
+/// (sub-pixel) content (issue #141).
///
/// Every child is given an equal share of the incoming width (`(maxWidth -
/// spacing * (n - 1)) / n`), matching the grid's fixed column count, so callers
diff --git a/lib/src/widgets/wind_full_height.dart b/lib/src/widgets/wind_full_height.dart
new file mode 100644
index 00000000..c808fa11
--- /dev/null
+++ b/lib/src/widgets/wind_full_height.dart
@@ -0,0 +1,231 @@
+import 'dart:math' as math;
+
+import 'package:flutter/rendering.dart';
+import 'package:flutter/widgets.dart';
+
+/// Fills the incoming height, falling back to [fallbackHeight] when there is
+/// none to fill.
+///
+/// This is what `h-full` composes to. The class exists because the question it
+/// answers, "is the incoming height bounded", is only answerable during layout,
+/// and the widget-layer way to ask it is a [LayoutBuilder]. A [LayoutBuilder]
+/// defers its whole subtree into a second layout pass, and `h-full` is common
+/// enough on a scrolling screen for that to show: a consumer measured 1056 of
+/// them in one eight-scroll session against 258 widget builds, one per element
+/// carrying the class, re-run on every frame.
+///
+/// A render object reads `constraints` directly, so it needs no deferral and no
+/// second pass. It is the same move [WindFractionBasis] and [WindMinWidthBox]
+/// already make for their own sizing questions.
+///
+/// [fallbackHeight] is passed in rather than read here because a render object
+/// has no [BuildContext] and so cannot reach [MediaQuery]. The caller reads it
+/// during build, which is where that lookup belongs anyway: it registers the
+/// dependency, so a rotation or a window resize rebuilds and updates it.
+class WindFullHeightBox extends SingleChildRenderObjectWidget {
+ /// The height to take when the incoming constraints do not bound one.
+ ///
+ /// The screen height, in every current caller. A `Column` child and a sliver
+ /// both offer an unbounded height, and "as tall as it wants" is not a size,
+ /// so `h-full` has to name a number instead.
+ final double fallbackHeight;
+
+ /// `1.0` for `w-full`, null to leave the width to the incoming constraints.
+ ///
+ /// Any fraction, not just 1.0: `w-1/2 h-full` reaches this class with 0.5.
+ /// An earlier version of this doc claimed 1.0 was the only value, which the
+ /// suite contradicts.
+ final double? widthFactor;
+
+ /// `max-w-*`, or null when the class is absent.
+ final double? maxWidth;
+
+ /// `max-h-*`, or null when the class is absent.
+ final double? maxHeight;
+
+ /// Creates a [WindFullHeightBox].
+ const WindFullHeightBox({
+ super.key,
+ required this.fallbackHeight,
+ this.widthFactor,
+ this.maxWidth,
+ this.maxHeight,
+ required super.child,
+ });
+
+ @override
+ RenderObject createRenderObject(BuildContext context) => _RenderFullHeight(
+ fallbackHeight,
+ widthFactor,
+ maxWidth,
+ maxHeight,
+ );
+
+ @override
+ void updateRenderObject(
+ BuildContext context,
+ covariant RenderObject renderObject,
+ ) {
+ (renderObject as _RenderFullHeight)
+ ..fallbackHeight = fallbackHeight
+ ..widthFactor = widthFactor
+ ..maxWidth = maxWidth
+ ..maxHeight = maxHeight;
+ }
+}
+
+class _RenderFullHeight extends RenderProxyBox {
+ _RenderFullHeight(
+ this._fallbackHeight,
+ this._widthFactor,
+ this._maxWidth,
+ this._maxHeight,
+ );
+
+ double _fallbackHeight;
+ set fallbackHeight(double value) {
+ if (value == _fallbackHeight) return;
+ _fallbackHeight = value;
+ markNeedsLayout();
+ }
+
+ double? _widthFactor;
+ set widthFactor(double? value) {
+ if (value == _widthFactor) return;
+ _widthFactor = value;
+ markNeedsLayout();
+ }
+
+ double? _maxWidth;
+ set maxWidth(double? value) {
+ if (value == _maxWidth) return;
+ _maxWidth = value;
+ markNeedsLayout();
+ }
+
+ double? _maxHeight;
+ set maxHeight(double? value) {
+ if (value == _maxHeight) return;
+ _maxHeight = value;
+ markNeedsLayout();
+ }
+
+ /// The height this box resolves to under [constraints].
+ ///
+ /// The bounded and unbounded cases differ only in where the number comes
+ /// from, and `max-h-*` clamps both. That is narrower than it sounds, and the
+ /// closing `constrainHeight` is why.
+ ///
+ /// Under a LOOSE bounded height the cap now applies where it used to be
+ /// discarded: the widget-layer version wrapped no `ConstrainedBox` on its
+ /// bounded branch at all, so `h-full max-h-[120px]` under a
+ /// `ConstrainedBox(maxHeight: 400)` rendered 400 and now renders 120.
+ ///
+ /// Under a TIGHT one it still yields the parent's height, and that is correct
+ /// rather than the same bug: a tight constraint is the parent stating an
+ /// exact size, and no className overrides it. `constrainHeight` is what keeps
+ /// this box honest about that.
+ double _heightFor(BoxConstraints constraints) {
+ final double available =
+ constraints.hasBoundedHeight ? constraints.maxHeight : _fallbackHeight;
+ final double capped =
+ _maxHeight == null ? available : math.min(available, _maxHeight!);
+
+ return constraints.constrainHeight(capped);
+ }
+
+ /// The width constraints to hand the child.
+ ///
+ /// Untouched unless a `w-*` fraction asked for a share, which is what the
+ /// widget-layer `SizedBox(width: double.infinity)` and
+ /// `FractionallySizedBox(widthFactor: ...)` both did in their own branches.
+ ///
+ /// The cap applies AFTER the fraction, and that is a fix rather than a port.
+ /// The widget-layer version put its `ConstrainedBox` outside the
+ /// `FractionallySizedBox`, but `BoxConstraints.enforce` clamps an additional
+ /// constraint into the incoming range, so against the tight width the
+ /// fraction had already produced the cap was discarded: `w-1/2 h-full
+ /// max-w-[100px]` in a 300 pixel parent rendered 150 and now renders 100.
+ (double, double) _widthRangeFor(BoxConstraints constraints) {
+ double minWidth = constraints.minWidth;
+ double maxWidth = constraints.maxWidth;
+
+ if (_widthFactor != null && constraints.hasBoundedWidth) {
+ final double target = constraints.maxWidth * _widthFactor!;
+ minWidth = target;
+ maxWidth = target;
+ }
+
+ if (_maxWidth != null && maxWidth > _maxWidth!) {
+ maxWidth = _maxWidth!;
+ if (minWidth > maxWidth) minWidth = maxWidth;
+ }
+
+ return (minWidth, maxWidth);
+ }
+
+ @override
+ void performLayout() {
+ final double height = _heightFor(constraints);
+ final (double minWidth, double maxWidth) = _widthRangeFor(constraints);
+
+ final RenderBox? target = child;
+ // Reachable: a childless `WDiv` (a rule, a divider, a spacer) carrying only
+ // `h-full` builds no core structure, so this box gets a null child.
+ if (target == null) {
+ size = constraints.constrain(Size(minWidth, height));
+ return;
+ }
+
+ target.layout(
+ BoxConstraints(
+ minWidth: minWidth,
+ maxWidth: maxWidth,
+ minHeight: height,
+ maxHeight: height,
+ ),
+ parentUsesSize: true,
+ );
+
+ // The width comes from the child so an unconstrained axis still hugs, the
+ // height from the resolution above so a bounded parent still gets the full
+ // fill it asked for.
+ size = constraints.constrain(Size(target.size.width, height));
+ }
+
+ // Intrinsics are deliberately left to `RenderProxyBox`, which forwards them
+ // to the child.
+ //
+ // That is the honest answer for a box whose job is to take what it is given:
+ // its natural height is its content's, and the fill happens against whatever
+ // the parent then offers. The first version overrode both to report
+ // [fallbackHeight], reasoning that a fill box "wants" the screen. Under an
+ // `IntrinsicHeight` beside a 60 pixel sibling that made the row 600 rather
+ // than 60, which is the opposite of what `h-full` means.
+ //
+ // Answering at all is the change. The old `LayoutBuilder` could not, so an
+ // `IntrinsicHeight` anywhere above `h-full` threw, and that limitation was
+ // documented rather than fixed.
+
+ @override
+ Size computeDryLayout(BoxConstraints constraints) {
+ final double height = _heightFor(constraints);
+ final (double minWidth, double maxWidth) = _widthRangeFor(constraints);
+
+ final RenderBox? target = child;
+ if (target == null) {
+ return constraints.constrain(Size(minWidth, height));
+ }
+
+ final Size childSize = target.getDryLayout(
+ BoxConstraints(
+ minWidth: minWidth,
+ maxWidth: maxWidth,
+ minHeight: height,
+ maxHeight: height,
+ ),
+ );
+
+ return constraints.constrain(Size(childSize.width, height));
+ }
+}
diff --git a/skills/wind-ui/SKILL.md b/skills/wind-ui/SKILL.md
index 6ed5eae0..fbe0496e 100644
--- a/skills/wind-ui/SKILL.md
+++ b/skills/wind-ui/SKILL.md
@@ -2,7 +2,7 @@
name: wind-ui
description: "fluttersdk_wind 1.5: utility-first Flutter styling with Tailwind-syntax className strings. 27 W-prefix widgets (WDiv, WText, WButton, WInput, WSelect, WDatePicker, WPopover, WCard, WTabs, plus five WForm* wrappers) parse className into a cached immutable WindStyle; WindRecipe and WindSlotRecipe compose variant classNames. Prefixes stack freely (dark: / hover: / focus: / md: / ios: / selected: / disabled: / custom), the last class in a family wins, an unrecognized token drops with a one-time kDebugMode hint, and every color token carries a dark: peer in the same className. TRIGGER when: writing or editing UI in a Flutter app that depends on fluttersdk_wind; any className string; any W-prefix widget; any WindTheme or WindThemeData reference; the user mentions Tailwind for Flutter, utility-first, className, or wind-ui. DO NOT TRIGGER when: backend, API, or state-management work that never touches a widget tree; a Flutter project without fluttersdk_wind in pubspec.yaml; Material-only widgets (Scaffold, AppBar, Dialog) with no Wind content inside."
when_to_use: "Any task that produces, modifies, or audits Wind-styled UI: composing a className, picking the right W-widget, wiring a Form field, customizing WindThemeData, pairing dark-mode classes, debugging a layout or a RenderFlex overflow, building a popover, rendering a JSON tree via WDynamic, or composing a WindRecipe. Load it before the first line of new UI, and equally when auditing UI that already exists."
-version: 2.13.2
+version: 2.14.0
---
@@ -231,7 +231,7 @@ Wind hides most boilerplate but never changes Flutter's "constraints down, sizes
`items-stretch` inside a `SingleChildScrollView` needs an `IntrinsicHeight` wrapper from native Flutter; Wind has no token for it. Rare; reach for it when row children inside a scroll must match heights.
-**Intrinsic sizing limitation.** Wrapping Wind content in `IntrinsicHeight` / `IntrinsicWidth` (or a `Row`/`Column` that needs child intrinsic heights for equal-height columns) throws `LayoutBuilder does not support returning intrinsic dimensions` WHEN that content triggers Wind's internal `LayoutBuilder` paths: `h-full` (only in an unbounded-height context) or a flex `basis-*` (a single `LayoutBuilder` around the surrounding flex). `LayoutBuilder` cannot answer intrinsic queries (a Flutter constraint, not a Wind bug). Escape hatches: use explicit `h-*` / `size-*` instead of `h-full`; do not wrap such content in `IntrinsicHeight`; for equal-height rows use a `Stack` + `Positioned(top:0,bottom:0)`. Wind's own `items-stretch` column equalizes cross-axis size without you adding `IntrinsicHeight` (it uses its own `LayoutBuilder` internally, so reach for it INSTEAD of `IntrinsicHeight`, not nested inside one).
+**Intrinsic sizing limitation.** Wrapping Wind content in `IntrinsicHeight` / `IntrinsicWidth` (or a `Row`/`Column` that needs child intrinsic heights for equal-height columns) throws `LayoutBuilder does not support returning intrinsic dimensions` WHEN that content contains a `grid`, which composes a `Wrap` inside a `LayoutBuilder` to compute its column width. `LayoutBuilder` cannot answer intrinsic queries (a Flutter constraint, not a Wind bug). `h-full` and flex `basis-*` are NO LONGER triggers: both resolve through render objects (`WindFullHeightBox`, `WindMainExtentProvider`), and a render object answers intrinsics. Escape hatches for the `grid` case: explicit `h-*` / `size-*` cells, or a `Stack` + `Positioned(top:0,bottom:0)`; Wind's own `items-stretch` grid equalizes row heights with real layout, so reach for it INSTEAD of `IntrinsicHeight`.
## 7. className formatting
diff --git a/skills/wind-ui/references/layouts.md b/skills/wind-ui/references/layouts.md
index 1e2ae4e6..de8c4f3b 100644
--- a/skills/wind-ui/references/layouts.md
+++ b/skills/wind-ui/references/layouts.md
@@ -387,7 +387,7 @@ WDiv(
> **Column width-stretch is intrinsic-free (no `IntrinsicHeight` needed).** The reverse case, a `flex flex-col items-stretch` (equal child WIDTHS), needs no `IntrinsicHeight`: Wind's column cross-axis stretch is a real render object (`WindCrossStretch`), and `basis-*` resolves without a `LayoutBuilder`. So a `flex flex-col` (with or without `basis-*`) renders under an `IntrinsicHeight`, a `Table`, or an `items-stretch` grid cell without asserting. Only the ROW height-match above still uses `IntrinsicHeight`.
-> **Caveat: `h-full` still fails through an `IntrinsicHeight`.** A child that resolves `h-full` in an unbounded-height context still uses a Wind internal `LayoutBuilder`, so under an `IntrinsicHeight` it throws `LayoutBuilder does not support returning intrinsic dimensions` (a Flutter constraint, not a Wind bug). Keep children explicitly sized under an `IntrinsicHeight` and avoid `h-full` there. (Fractional `basis-*` no longer triggers this: it resolves against the flex's own extent via a real render object.) For equal-height cards outside a scroll, prefer fixed `h-*` cells or a `Stack` + `Positioned(top:0,bottom:0)`.
+> **`h-full` is intrinsic-safe now.** It used to resolve through a Wind-internal `LayoutBuilder` and throw `LayoutBuilder does not support returning intrinsic dimensions` under an `IntrinsicHeight`; it is the `WindFullHeightBox` render object now, so it renders there, in a `Table` cell, and in an `items-stretch` grid cell without asserting, and it reports its child's intrinsic height so it matches the tallest sibling rather than the screen. The one remaining `LayoutBuilder` path is `grid`. For equal-height cards outside a scroll, `h-full` under an `IntrinsicHeight` now works; a `Stack` + `Positioned(top:0,bottom:0)` remains the cheaper shape.
---
diff --git a/skills/wind-ui/references/tailwind-divergence.md b/skills/wind-ui/references/tailwind-divergence.md
index f3e12cdb..3602f3b4 100644
--- a/skills/wind-ui/references/tailwind-divergence.md
+++ b/skills/wind-ui/references/tailwind-divergence.md
@@ -44,7 +44,7 @@ The base rule: Wind aims for syntactic familiarity, not semantic equivalence. Mo
| `w-full` inside a flex Row | Works (max-width: 100%) | A bare `w-full` row child is treated as `flex-1` (wrapped in `Expanded`) and fills the row; `flex-1` is the clearer, idiomatic choice. `md:w-full` is not auto-expanded. |
| `h-full` inside a vertical scroll | Works (max-height: 100%) | Raises an actionable dev assert ("use `flex-1` inside a `flex flex-col` instead of `h-full` inside a vertical scroll"); the scrollable parent threads a vertical-unbounded flag so the child can fail fast. Stripped in release. |
| `overflow-x-auto` + `w-full min-w-[Npx]` (shadcn Table pattern) | Fills container, scrolls when content exceeds it | Same behavior, composed from existing tokens (no new token). `w-full` inside a horizontal scroll is sized to `max(viewport, min-w-*)` via the threaded viewport width instead of asserting on the scroll's unbounded width: fills on wide, scrolls on narrow. |
-| `IntrinsicHeight` / `IntrinsicWidth` (Flutter widget, not a token) | N/A | Wind's own flex is intrinsic-safe: a `flex flex-col` (smart stretch, explicit `items-stretch`, or `basis-*`) uses NO `LayoutBuilder` (column stretch is the `WindCrossStretch` render object; `basis-*` resolves against the flex extent via `WindMainExtentProvider`), so it renders under an `IntrinsicHeight`/`Table`/grid cell without asserting. Still avoid wrapping ARBITRARY subtrees in `IntrinsicHeight`/`IntrinsicWidth` inside sheet/route open animations (they can raise `RenderBox was not laid out`); for a connector or rail prefer a `Stack` + `Positioned(top: 0, bottom: 0)` line or wind's `items-stretch` column. Wind itself uses no `IntrinsicHeight` or `IntrinsicWidth`. |
+| `IntrinsicHeight` / `IntrinsicWidth` (Flutter widget, not a token) | N/A | Wind's own flex is intrinsic-safe, and so is `h-full` (the `WindFullHeightBox` render object): a `flex flex-col` (smart stretch, explicit `items-stretch`, or `basis-*`) uses NO `LayoutBuilder` (column stretch is the `WindCrossStretch` render object; `basis-*` resolves against the flex extent via `WindMainExtentProvider`), so it renders under an `IntrinsicHeight`/`Table`/grid cell without asserting. Still avoid wrapping ARBITRARY subtrees in `IntrinsicHeight`/`IntrinsicWidth` inside sheet/route open animations (they can raise `RenderBox was not laid out`); for a connector or rail prefer a `Stack` + `Positioned(top: 0, bottom: 0)` line or wind's `items-stretch` column. Wind itself uses no `IntrinsicHeight` or `IntrinsicWidth`. |
| `overflow-y-auto` | Native browser scrollbar | Renders Flutter scroll view; needs constructor `scrollPrimary: true` for iOS tap-to-top |
| `bg-red-500/50` | Color with 50% opacity (v3.x+) | Same syntax, same semantics |
| `dark:bg-gray-900` | Active only when dark mode is enabled | Active only when `WindThemeData.brightness == Brightness.dark`; required to pair every color |
diff --git a/skills/wind-ui/references/tokens.md b/skills/wind-ui/references/tokens.md
index b56de159..ec21832c 100644
--- a/skills/wind-ui/references/tokens.md
+++ b/skills/wind-ui/references/tokens.md
@@ -79,7 +79,7 @@ Inline color escape hatches that bypass the cache key:
| `align-content-start` / `-end` / `-center` / `-between` / `-around` / `-evenly` / `-stretch` | Wrap-only, `WrapAlignment` for runs |
| `align-self-start` / `-end` / `-center` / `-stretch` / `-auto` (or the `self-*` shorthand) | Per-child cross-axis override |
| `axis-min` / `axis-max` | Wind-only: `MainAxisSize.min` / `.max` on the parent flex |
-| `grid-cols-N` | N columns (any integer); renders as `Wrap` with computed column widths. Add `items-stretch` for equal-height rows: each row is a real-layout equal-height row (measures each cell, re-lays to at least the tallest via a MIN height, never a tight squeeze), NOT `IntrinsicHeight`, so cells containing a `flex flex-col`, `h-full`, or `basis-*` (LayoutBuilder-bearing) stretch without asserting or overflowing (#139, #141) |
+| `grid-cols-N` | N columns (any integer); renders as `Wrap` with computed column widths. Add `items-stretch` for equal-height rows: each row is a real-layout equal-height row (measures each cell, re-lays to at least the tallest via a MIN height, never a tight squeeze), NOT `IntrinsicHeight`, so cells containing a `flex flex-col`, `h-full`, or `basis-*` stretch without asserting or overflowing (#139, #141); none of those three carries a `LayoutBuilder` any more |
| `order-0` through `order-12` | Child order index |
| `order-first` / `order-last` / `order-none` | Sentinel order (first=-9999, last=9999, none=0) |
| `order-[N]` | Arbitrary signed integer (e.g. `order-[-5]`) |
diff --git a/test/flex/intrinsic_safe_layout_test.dart b/test/flex/intrinsic_safe_layout_test.dart
index 3ce12332..898471c4 100644
--- a/test/flex/intrinsic_safe_layout_test.dart
+++ b/test/flex/intrinsic_safe_layout_test.dart
@@ -363,4 +363,76 @@ void main() {
expect(error.toString(), contains('flex-1'));
});
});
+
+ group('(e) h-full is intrinsic-safe', () {
+ // It was not, and the limitation was documented rather than fixed: `h-full`
+ // resolved through a `LayoutBuilder`, which cannot answer an intrinsic
+ // query, so any `IntrinsicHeight` above it threw. `SKILL.md`, three
+ // reference pages and `doc/layout/sizing.md` all carried the caveat and the
+ // escape hatch ("use explicit h-* instead").
+ //
+ // `h-full` is a render object now (`WindFullHeightBox`), and a render
+ // object answers intrinsics. Verified as an A/B against master, which
+ // throws `LayoutBuilder does not support returning intrinsic dimensions` on
+ // exactly this tree.
+ testWidgets('h-full under an IntrinsicHeight no longer throws', (
+ tester,
+ ) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ home: WindTheme(
+ data: WindThemeData(),
+ child: Column(
+ children: [
+ IntrinsicHeight(
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: const [
+ SizedBox(width: 80, height: 60),
+ WDiv(className: 'h-full w-[40px] bg-red-500'),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+
+ expect(tester.takeException(), isNull);
+ });
+
+ testWidgets('and it matches the tallest sibling rather than collapsing', (
+ tester,
+ ) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ home: WindTheme(
+ data: WindThemeData(),
+ child: Column(
+ children: [
+ IntrinsicHeight(
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: const [
+ SizedBox(width: 80, height: 60),
+ WDiv(
+ className: 'h-full w-[40px]',
+ child: SizedBox.expand(),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+
+ // The 60 pixel sibling sets the row height; `h-full` fills it. Asserting
+ // the number rather than just the absence of a throw, because a box that
+ // silently collapsed to zero would also not throw.
+ expect(tester.getSize(find.byType(SizedBox).last).height, 60);
+ });
+ });
}
diff --git a/test/widgets/w_div/full_height_sizing_test.dart b/test/widgets/w_div/full_height_sizing_test.dart
new file mode 100644
index 00000000..974ebd74
--- /dev/null
+++ b/test/widgets/w_div/full_height_sizing_test.dart
@@ -0,0 +1,357 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:fluttersdk_wind/fluttersdk_wind.dart';
+
+/// Characterisation tests for `h-full`, pinned before its implementation moves
+/// off `LayoutBuilder`.
+///
+/// `h-full` has to answer a question only layout can answer: is the incoming
+/// height bounded. When it is, the element fills it; when it is not (a `Column`
+/// child, a sliver), there is nothing to fill and the element falls back to the
+/// screen height. That branch is why the path is wrapped in a `LayoutBuilder`,
+/// and a `LayoutBuilder` defers its whole subtree into a second layout pass:
+/// measured in a consumer at 1056 of them in one eight-scroll session against
+/// 258 widget builds.
+///
+/// Every case below passes against the `LayoutBuilder` implementation. They are
+/// written first precisely so that the replacement can be judged by whether
+/// they all still pass, rather than by reading two layout algorithms side by
+/// side and hoping.
+///
+/// The combinations that matter are boundedness, the presence of a width
+/// factor, and `max-h-*`, because those are the three things the old code
+/// branched on.
+///
+/// Everything is measured on a keyed CHILD rather than on the `WDiv` itself.
+/// `FractionallySizedBox` is an overflow box: it takes the space it is offered
+/// and applies the factor to its child, so `getSize(find.byType(WDiv))` reports
+/// the offered space in every case and cannot tell `h-1/2` from `h-full`. The
+/// first version of this file measured there and read four passing behaviours
+/// as failures.
+void main() {
+ setUp(WindParser.clearCache);
+
+ /// Pumps [child] inside a box of a known size, so `h-full` sees a BOUNDED
+ /// height of exactly 400.
+ Future pumpBounded(WidgetTester tester, Widget child) {
+ return tester.pumpWidget(
+ MaterialApp(
+ home: WindTheme(
+ data: WindThemeData(),
+ child: Center(
+ child: SizedBox(width: 300, height: 400, child: child),
+ ),
+ ),
+ ),
+ );
+ }
+
+ /// Pumps [child] where the vertical axis is UNBOUNDED, which is what a
+ /// `Column` hands its children.
+ Future pumpUnbounded(WidgetTester tester, Widget child) {
+ return tester.pumpWidget(
+ MaterialApp(
+ home: WindTheme(
+ data: WindThemeData(),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [SizedBox(width: 300, child: child)],
+ ),
+ ),
+ ),
+ );
+ }
+
+ group('h-full in a bounded height', () {
+ testWidgets('fills the incoming height', (tester) async {
+ await pumpBounded(
+ tester, const WDiv(className: 'h-full', child: _Probe()));
+
+ expect(tester.getSize(find.byType(_Probe)).height, 400);
+ });
+
+ testWidgets('with w-full fills both axes', (tester) async {
+ await pumpBounded(
+ tester, const WDiv(className: 'w-full h-full', child: _Probe()));
+
+ expect(tester.getSize(find.byType(_Probe)), const Size(300, 400));
+ });
+
+ testWidgets('a TIGHT parent wins over max-h-*, which is correct', (
+ tester,
+ ) async {
+ // `pumpBounded` uses a `SizedBox(height: 400)`, a tight constraint: the
+ // parent is stating an exact size, and no className overrides that. The
+ // first version of this file asserted 120 here and called the 400 a
+ // defect; it is Flutter's constraint model working.
+ await pumpBounded(
+ tester,
+ const WDiv(className: 'h-full max-h-[120px]', child: _Probe()),
+ );
+
+ expect(tester.getSize(find.byType(_Probe)).height, 400);
+ });
+
+ testWidgets('a LOOSE parent honours max-h-*, which master did not', (
+ tester,
+ ) async {
+ // The behaviour that actually changed. Master wrapped no `ConstrainedBox`
+ // on its bounded branch, so the cap was discarded outright: this rendered
+ // 400 there and renders 120 here. Verified as an A/B against master.
+ await tester.pumpWidget(
+ MaterialApp(
+ home: WindTheme(
+ data: WindThemeData(),
+ child: Align(
+ alignment: Alignment.topLeft,
+ child: ConstrainedBox(
+ constraints:
+ const BoxConstraints(maxWidth: 300, maxHeight: 400),
+ child: const WDiv(
+ className: 'h-full max-h-[120px]',
+ child: _Probe(),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ expect(tester.getSize(find.byType(_Probe)).height, 120);
+ });
+
+ testWidgets('max-w-* applies after the width fraction', (tester) async {
+ // Also a fix. Master put its `ConstrainedBox` outside the
+ // `FractionallySizedBox`, and `BoxConstraints.enforce` clamps an
+ // additional constraint into the incoming range, so against the tight
+ // width the fraction had already produced the cap was discarded:
+ // `clamp(100, 150, 150)` is 150. Master renders 150, this renders 100.
+ await pumpBounded(
+ tester,
+ const WDiv(className: 'w-1/2 h-full max-w-[100px]', child: _Probe()),
+ );
+
+ expect(tester.getSize(find.byType(_Probe)).width, 100);
+ });
+
+ testWidgets('a child is laid out against the filled height', (
+ tester,
+ ) async {
+ await pumpBounded(
+ tester,
+ const WDiv(
+ className: 'h-full',
+ child: WDiv(className: 'h-full bg-red-500'),
+ ),
+ );
+
+ // Both boxes fill: the inner one sees the outer's 400 as its own bound.
+ for (final Size size in tester.widgetList(find.byType(WDiv)).map(
+ (WDiv w) => tester.getSize(find.byWidget(w)),
+ )) {
+ expect(size.height, 400);
+ }
+ });
+ });
+
+ group('h-full in an unbounded height', () {
+ testWidgets('falls back to the screen height rather than asserting', (
+ tester,
+ ) async {
+ await pumpUnbounded(
+ tester, const WDiv(className: 'h-full', child: _Probe()));
+
+ final double screen =
+ tester.view.physicalSize.height / tester.view.devicePixelRatio;
+ expect(tester.getSize(find.byType(_Probe)).height, screen);
+ });
+
+ testWidgets('max-h-* clamps the screen-height fallback', (tester) async {
+ await pumpUnbounded(
+ tester,
+ const WDiv(className: 'h-full max-h-[120px]', child: _Probe()),
+ );
+
+ expect(tester.getSize(find.byType(_Probe)).height, 120);
+ });
+
+ testWidgets('with w-full still fills the bounded width', (tester) async {
+ await pumpUnbounded(
+ tester, const WDiv(className: 'w-full h-full', child: _Probe()));
+
+ expect(tester.getSize(find.byType(_Probe)).width, 300);
+ });
+ });
+
+ group('the outer box, which the child measurements do not see', () {
+ // Pinned because the replacement must not change what the element itself
+ // reports to ITS parent, only how it gets there. `FractionallySizedBox` is
+ // an overflow box and takes the space it is offered; the unbounded branch
+ // is a `SizedBox` and hugs the height it chose.
+ testWidgets('takes the offered space when the height is bounded', (
+ tester,
+ ) async {
+ await pumpBounded(
+ tester, const WDiv(className: 'h-full', child: _Probe()));
+
+ expect(tester.getSize(find.byType(WDiv)), const Size(300, 400));
+ });
+
+ testWidgets('hugs the fallback height when there is none to fill', (
+ tester,
+ ) async {
+ await pumpUnbounded(
+ tester, const WDiv(className: 'h-full', child: _Probe()));
+
+ final double screen =
+ tester.view.physicalSize.height / tester.view.devicePixelRatio;
+ expect(tester.getSize(find.byType(WDiv)).height, screen);
+ });
+ });
+
+ group('h-fraction is unaffected, in either context', () {
+ testWidgets('h-1/2 takes half a bounded height', (tester) async {
+ await pumpBounded(
+ tester, const WDiv(className: 'h-1/2', child: _Probe()));
+
+ expect(tester.getSize(find.byType(_Probe)).height, 200);
+ });
+
+ testWidgets('w-1/2 h-1/2 takes half of both', (tester) async {
+ await pumpBounded(
+ tester, const WDiv(className: 'w-1/2 h-1/2', child: _Probe()));
+
+ expect(tester.getSize(find.byType(_Probe)), const Size(150, 200));
+ });
+ });
+
+ group('h-full updates in place', () {
+ // `updateRenderObject` and the four setters. A `WDiv` that keeps its
+ // identity while its className changes must re-lay-out rather than keep the
+ // size it resolved the first time, and the same applies when the screen
+ // itself changes: `fallbackHeight` is read during build, so a rotation is
+ // an update rather than a rebuild from scratch.
+ testWidgets('a changed max-h-* re-resolves the height', (tester) async {
+ await pumpUnbounded(
+ tester,
+ const WDiv(className: 'h-full max-h-[120px]', child: _Probe()),
+ );
+ expect(tester.getSize(find.byType(_Probe)).height, 120);
+
+ await pumpUnbounded(
+ tester,
+ const WDiv(className: 'h-full max-h-[200px]', child: _Probe()),
+ );
+ expect(tester.getSize(find.byType(_Probe)).height, 200);
+ });
+
+ testWidgets('a changed width factor re-resolves the width', (tester) async {
+ await pumpBounded(
+ tester,
+ const WDiv(className: 'w-full h-full', child: _Probe()),
+ );
+ expect(tester.getSize(find.byType(_Probe)).width, 300);
+
+ await pumpBounded(
+ tester,
+ const WDiv(className: 'w-1/2 h-full', child: _Probe()),
+ );
+ expect(tester.getSize(find.byType(_Probe)).width, 150);
+ });
+
+ testWidgets('a changed max-w-* re-resolves the width', (tester) async {
+ await pumpBounded(
+ tester,
+ const WDiv(className: 'w-full h-full max-w-[200px]', child: _Probe()),
+ );
+ expect(tester.getSize(find.byType(_Probe)).width, 200);
+
+ await pumpBounded(
+ tester,
+ const WDiv(className: 'w-full h-full max-w-[100px]', child: _Probe()),
+ );
+ expect(tester.getSize(find.byType(_Probe)).width, 100);
+ });
+
+ testWidgets('a changed screen height re-resolves the fallback', (
+ tester,
+ ) async {
+ addTearDown(tester.view.reset);
+
+ tester.view.physicalSize = const Size(400, 800);
+ tester.view.devicePixelRatio = 1.0;
+ await pumpUnbounded(
+ tester, const WDiv(className: 'h-full', child: _Probe()));
+ expect(tester.getSize(find.byType(_Probe)).height, 800);
+
+ tester.view.physicalSize = const Size(400, 500);
+ await pumpUnbounded(
+ tester, const WDiv(className: 'h-full', child: _Probe()));
+ expect(tester.getSize(find.byType(_Probe)).height, 500);
+ });
+ });
+
+ group('h-full with no child', () {
+ // A childless `WDiv` is a real shape (a divider, a rule, a spacer), and the
+ // box still has to report a size rather than reaching for a child that is
+ // not there.
+ testWidgets('still resolves a height with nothing to lay out', (
+ tester,
+ ) async {
+ // No `w-[40px]`: any explicit width makes `WDiv` build a core structure,
+ // so the box gets a child and this stops testing the null branch. The
+ // first version carried one and covered nothing.
+ await pumpUnbounded(tester, const WDiv(className: 'h-full'));
+
+ final double screen =
+ tester.view.physicalSize.height / tester.view.devicePixelRatio;
+ expect(tester.getSize(find.byType(WDiv)).height, screen);
+ });
+ });
+
+ group('h-full answers a dry layout', () {
+ // Asked directly, because the widget trees that route through
+ // `computeDryLayout` do so from inside their own layout and are awkward to
+ // build on purpose. The contract is what matters: a dry layout has to agree
+ // with the real one, or a parent that measures before laying out gets a
+ // different answer than the one it then renders.
+ testWidgets('and agrees with the size it then lays out', (tester) async {
+ await pumpBounded(
+ tester,
+ const WDiv(className: 'w-full h-full', child: _Probe()),
+ );
+
+ final RenderBox box = tester.renderObject(
+ find.byType(WDiv),
+ );
+ const BoxConstraints incoming = BoxConstraints(
+ maxWidth: 300,
+ maxHeight: 400,
+ );
+
+ expect(box.getDryLayout(incoming), box.size);
+ });
+
+ testWidgets('with no child, and with an unbounded height', (tester) async {
+ await pumpUnbounded(tester, const WDiv(className: 'h-full'));
+
+ final RenderBox box = tester.renderObject(
+ find.byType(WDiv),
+ );
+
+ expect(
+ box.getDryLayout(const BoxConstraints(maxWidth: 300)).height,
+ box.size.height,
+ );
+ });
+ });
+}
+
+/// A child that fills whatever it is given, so its measured size IS the box the
+/// sizing classes produced.
+class _Probe extends StatelessWidget {
+ const _Probe();
+
+ @override
+ Widget build(BuildContext context) => const SizedBox.expand();
+}
diff --git a/test/widgets/w_div/sizing_test.dart b/test/widgets/w_div/sizing_test.dart
index d77a9f64..19b53cd8 100644
--- a/test/widgets/w_div/sizing_test.dart
+++ b/test/widgets/w_div/sizing_test.dart
@@ -101,12 +101,18 @@ void main() {
),
);
- expect(find.byType(FractionallySizedBox), findsOneWidget);
- final FractionallySizedBox box = tester.widget(
- find.byType(FractionallySizedBox),
- );
- expect(box.widthFactor, 0.5);
- expect(box.heightFactor, 1.0);
+ // Asserted on the resulting SIZE rather than on the widget that
+ // produced it. The previous version looked for a `FractionallySizedBox`
+ // carrying the two factors, which pinned one composition rather than the
+ // behaviour: `h-full` now resolves through `WindFullHeightBox` and the
+ // element sizes identically. A white-box assertion here fails on a change
+ // that a user cannot see, and passes on one they can.
+ final Size size = tester.getSize(find.text('Test'));
+ final Size screen =
+ tester.view.physicalSize / tester.view.devicePixelRatio;
+
+ expect(size.width, screen.width / 2);
+ expect(size.height, screen.height);
});
group('Sizing Optimization Tests', () {
@@ -200,12 +206,24 @@ void main() {
),
);
- final wDivFinder = find.byType(WDiv);
+ // The height it RESOLVED TO, not the widget it used to get there.
+ // This asserted a `LayoutBuilder` descendant, which is the thing the
+ // render-layer rewrite removed on purpose: `h-full` in an unbounded
+ // column still falls back to the screen height, and that fallback is
+ // the behaviour worth pinning.
+ final double screenHeight =
+ tester.view.physicalSize.height / tester.view.devicePixelRatio;
+ expect(tester.getSize(find.text('Test')).height, screenHeight);
+
+ // Deliberately no assertion about WHICH widget produced that height.
+ // The old one named `LayoutBuilder`, and naming its replacement would
+ // repeat the mistake: the next rewrite would fail this test without
+ // changing anything a user can observe.
expect(
- find.descendant(of: wDivFinder, matching: find.byType(LayoutBuilder)),
+ find.byType(WDiv),
findsOneWidget,
reason:
- 'h-full in unbounded parent requires LayoutBuilder to check constraints',
+ 'h-full in an unbounded parent falls back to the screen height',
);
});
});