From 23045370325861b6bc391733bf2b17dcbe3e5efc Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Wed, 1 Jul 2026 19:19:25 +0300 Subject: [PATCH 1/2] fix(grid): stretch cells with a min height to drop the 2px overflow (#141) Follow-up to #139. WindEqualHeightRow re-laid each cell to a TIGHT height equal to the loose-measured row max. A cell whose flex flex-col content needs a hair more under the tight re-lay than the loose measure reported (sub-pixel text/flex rounding on real rendering, e.g. CanvasKit) then overflowed by ~2px ("RenderFlex overflowed by 2.0 pixels on the bottom") on the stretched row. Stretch with a MIN height instead of a tight one: a cell is laid out with minHeight = the row target and an unbounded (or the incoming) max, so it is never forced below its own content, and the row takes the tallest resulting height. Overflow is impossible by construction; the common (integer-metric) case still yields perfectly equal cells. Note: the 2px overflow is specific to fractional glyph metrics (CanvasKit) and is not reproducible in the flutter_test binding, so the guard test asserts no exception + equal heights; the fix is by construction (min-height never squeezes content). Post-change sync: grid_stretch_test #141 no-overflow case; doc/layout/grid.md, skill (tokens.md, version 2.8.2), CHANGELOG. Closes #141 --- CHANGELOG.md | 2 ++ doc/layout/grid.md | 2 +- lib/src/widgets/w_div.dart | 6 ++-- lib/src/widgets/wind_equal_height_row.dart | 21 ++++++++++++-- skills/wind-ui/SKILL.md | 4 +-- skills/wind-ui/references/tokens.md | 2 +- test/widgets/w_div/grid_stretch_test.dart | 32 ++++++++++++++++++++++ 7 files changed, 60 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54957ae..3f28a0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0. ### Fixed +- `grid ... items-stretch` no longer emits a residual `RenderFlex overflowed by ~2px on the bottom` when it stretches an unequal cell (follow-up to #139). `WindEqualHeightRow` previously re-laid each cell to a TIGHT height equal to the loose-measured row max; a cell whose `flex flex-col` content needs a hair more under the tight re-lay (sub-pixel text/flex rounding on real rendering, e.g. CanvasKit) overflowed by a couple of pixels. It now stretches with a MIN height instead (never a tight squeeze), so a cell is never forced below its own content and the row takes the tallest resulting height — overflow-free by construction. (`lib/src/widgets/wind_equal_height_row.dart`) (#141) + - `grid ... items-stretch` no longer asserts `RenderBox was not laid out` (rooted at `RenderIntrinsicHeight` / `LayoutBuilder does not support returning intrinsic dimensions`) when it stretches an unequal `flex flex-col` cell. The equal-height mechanism from #126 used `IntrinsicHeight`, which queries child intrinsics; a `flex flex-col` cell (or one using `h-full` / `basis-*`) carries a `LayoutBuilder` that cannot answer intrinsic queries, so stretching a shorter cell crashed the exact case the feature exists for. Replaced it with `WindEqualHeightRow`, a `RenderObject` that measures each cell with a real loose-height layout and re-lays it tight to the row max, so LayoutBuilder-bearing cells stretch correctly. As a bonus, `h-full` on a stretched cell now resolves too. (`lib/src/widgets/wind_equal_height_row.dart`, `lib/src/widgets/w_div.dart`) (#139) - Alias expansion now resolves state/breakpoint-prefixed alias tokens instead of silently dropping them. A prefixed token (`hover:bg-surface-container`, `md:row`, `dark:hover:row`) had its whole form rejected before the alias lookup, so `prefix:` + a known alias key was a silent no-op while plain `bg-surface-container` worked. The expander now peels the prefix chain off, matches the bare body against the (still bare-only) alias map, and re-applies the prefix to every produced token (`md:row` -> `md:flex md:flex-row`; `hover:bg-surface` carries `hover:` onto each token including the value's own `dark:` peer, yielding `hover:dark:...`, which the parser resolves regardless of prefix order). Cycle/depth/output guards are unchanged and now key on the bare body. (`lib/src/parser/alias_expander.dart`) (#124) diff --git a/doc/layout/grid.md b/doc/layout/grid.md index bd47933..edb86ff 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 tight to the row's tallest), 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`. Cells simply fill the row height. +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). ## Responsive diff --git a/lib/src/widgets/w_div.dart b/lib/src/widgets/w_div.dart index 472cf88..6be26a0 100644 --- a/lib/src/widgets/w_div.dart +++ b/lib/src/widgets/w_div.dart @@ -1160,7 +1160,8 @@ class WDiv extends StatelessWidget { /// /// A `Column` of [WindEqualHeightRow]s, `cols` cells per row. Each row lays /// its cells out for real (a loose-height pass to measure the tallest, then a - /// tight re-layout to that height) and gives each an equal width share, so + /// re-layout to at least that height via a min constraint) and gives each an + /// equal width share, so /// every cell matches the row's tallest. The last row is padded with blank /// cells so columns stay aligned. /// @@ -1196,7 +1197,8 @@ class WDiv extends StatelessWidget { rows.add(SizedBox(height: gapY)); } // WindEqualHeightRow measures each cell with a real (loose-height) layout - // and re-lays it tight to the row max, instead of IntrinsicHeight's + // and re-lays it to at least the row max (min height), instead of the + // intrinsic query IntrinsicHeight would run, // intrinsic query. A `flex flex-col` cell (which carries a LayoutBuilder) // can then be stretched without the "LayoutBuilder does not support // returning intrinsic dimensions" assert (#139). diff --git a/lib/src/widgets/wind_equal_height_row.dart b/lib/src/widgets/wind_equal_height_row.dart index 84e1151..f07ccd5 100644 --- a/lib/src/widgets/wind_equal_height_row.dart +++ b/lib/src/widgets/wind_equal_height_row.dart @@ -107,17 +107,32 @@ class _RenderEqualHeightRow extends RenderBox // Honor the incoming height constraint: if the parent forces a taller (or // caps at a shorter) row, cells stretch to that height, not just the // measured tallest, so the row and its cells stay consistent. - final double rowHeight = + final double target = maxHeight.clamp(constraints.minHeight, constraints.maxHeight); - // Pass 2: stretch every cell to the row height and position left to right. + // Pass 2: stretch every cell to at least `target` using a MIN height (never + // a tight height) and position it. A tight height would squeeze a cell + // whose content, laid out for real, needs a hair more than the + // loose-measured max (sub-pixel text/flex rounding) and produce a + // "RenderFlex overflowed by ~2px" warning (#141). A min constraint instead + // lets such a cell grow, so the re-laid height is always >= the cell's own + // content and overflow is impossible. The row takes the tallest resulting + // height; in the common case every cell settles at `target` (equal), and in + // the rare sub-pixel case a grown cell simply sets a marginally taller row. + double rowHeight = target; double dx = 0; child = firstChild; while (child != null) { child.layout( - BoxConstraints.tightFor(width: cellWidth, height: rowHeight), + BoxConstraints( + minWidth: cellWidth, + maxWidth: cellWidth, + minHeight: target, + maxHeight: constraints.maxHeight, + ), parentUsesSize: true, ); + if (child.size.height > rowHeight) rowHeight = child.size.height; (child.parentData as _EqualHeightParentData).offset = Offset(dx, 0); dx += cellWidth + _spacing; child = childAfter(child); diff --git a/skills/wind-ui/SKILL.md b/skills/wind-ui/SKILL.md index 61eaf5b..9eea9e7 100644 --- a/skills/wind-ui/SKILL.md +++ b/skills/wind-ui/SKILL.md @@ -3,10 +3,10 @@ name: wind-ui description: "fluttersdk_wind 1.1: utility-first Flutter styling with Tailwind-syntax className strings. 27 public widgets (WDiv, WText, WButton, WInput, WSelect, WCheckbox, WDatePicker, WPopover, WAnchor, WIcon, WImage, WSvg, WSpacer, WBreakpoint, WDynamic, WKeyboardActions, WindAnimationWrapper, WBadge, WCard, WSwitch, WRadio, WTabs + 5 WForm* wrappers) consume className through a 20-parser pipeline (20 implementation files organized into 12 token families for teaching) that emits a cached immutable WindStyle. WindRecipe / WindSlotRecipe compose className variants (base + axes + compoundVariants + caller) in strict emission order, no dedupe/sort/twMerge. Prefixes stack freely (dark: / hover: / focus: / md: / lg: / ios: / android: / web: / mobile: / selected: / loading: / disabled: / readonly: / error: / checked: / custom). Last class wins; unknown tokens fail silently. Every color token (bg-, text-, border-, ring-, shadow-, fill-) needs a dark: pair in the same className. TRIGGER when: writing or editing any UI in a Flutter app that depends on `fluttersdk_wind`; any className string; any W-prefix widget; any WindTheme / WindThemeData reference; the user mentions Tailwind for Flutter, utility-first, className, or wind-ui. DO NOT TRIGGER when: backend / API / state-management work that does not touch a widget tree; Flutter projects that do not have fluttersdk_wind in pubspec.yaml; Material-only widgets (Scaffold, AppBar, Dialog) without Wind content inside them." when_to_use: | Any task that produces, modifies, or audits Wind-styled Flutter UI: composing a className string, picking the right W-widget for a use case, integrating with a Form / FormField, customizing WindThemeData, wiring dark-mode pairs, debugging an unexpected layout, recovering from RenderFlex overflow, building a popover or dropdown, rendering a JSON tree via WDynamic, wiring Wind.installDebugResolver for kDebugMode tooling, migrating a Tailwind className from web, or composing a WindRecipe / WindSlotRecipe for a variant-driven component. Apply BEFORE writing the first line of UI in a Wind-using file, not as an audit pass. -version: 2.8.1 +version: 2.8.2 --- - + # Wind UI 1.1 diff --git a/skills/wind-ui/references/tokens.md b/skills/wind-ui/references/tokens.md index 35a9967..78bcbef 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 tight to the tallest), NOT `IntrinsicHeight`, so cells containing a `flex flex-col`, `h-full`, or `basis-*` (LayoutBuilder-bearing) stretch without asserting (#139) | +| `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) | | `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/widgets/w_div/grid_stretch_test.dart b/test/widgets/w_div/grid_stretch_test.dart index 003f891..d1f9701 100644 --- a/test/widgets/w_div/grid_stretch_test.dart +++ b/test/widgets/w_div/grid_stretch_test.dart @@ -164,5 +164,37 @@ void main() { cellOf(tester, 'Revenue').height, cellOf(tester, 'Signups').height); expect(cellOf(tester, 'Revenue').height, greaterThan(0)); }); + + testWidgets( + 'stretching an unequal flex-col cell emits no overflow warning (#141)', + (tester) async { + // Follow-up to #139: the stretch must not leave a residual RenderFlex + // overflow on the stretched row. WindEqualHeightRow uses a min-height + // (never a tight squeeze), so a stretched cell's content is never forced + // below its own height. + await tester.pumpWidget( + wrapWithTheme( + WDiv( + className: 'grid grid-cols-2 gap-4 items-stretch', + children: [ + // taller (3 rows) + WDiv( + className: 'flex flex-col gap-1', + children: const [WText('A'), WText('1'), WText('note')], + ), + // shorter (2 rows) -> stretched to the taller + WDiv( + className: 'flex flex-col gap-1', + children: const [WText('B'), WText('2')], + ), + ], + ), + ), + ); + + // No "RenderFlex overflowed" (or any) exception, and heights match. + expect(tester.takeException(), isNull); + expect(cellOf(tester, 'A').height, cellOf(tester, 'B').height); + }); }); } From cb477b7f5a3df3aadedf6efee948889a04707c4a Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Wed, 1 Jul 2026 19:28:50 +0300 Subject: [PATCH 2/2] fix(grid): equalize stretched cells + fix dup comment (PR #142 review) - Restore equal heights: after the min-height measure pass settles the true row height, a third pass re-lays every cell to that height (still via a MIN constraint, never tight), so a cell that measured shorter than a sibling which grew is brought up to match. All cells share the row height, and none is ever squeezed below its content (overflow-free). - Fix the duplicated 'intrinsic query ... intrinsic query.' fragment in the _buildStretchGrid dartdoc. --- lib/src/widgets/w_div.dart | 10 ++--- lib/src/widgets/wind_equal_height_row.dart | 43 ++++++++++++++++------ 2 files changed, 36 insertions(+), 17 deletions(-) diff --git a/lib/src/widgets/w_div.dart b/lib/src/widgets/w_div.dart index 6be26a0..ee9d1b9 100644 --- a/lib/src/widgets/w_div.dart +++ b/lib/src/widgets/w_div.dart @@ -1197,11 +1197,11 @@ class WDiv extends StatelessWidget { rows.add(SizedBox(height: gapY)); } // WindEqualHeightRow measures each cell with a real (loose-height) layout - // and re-lays it to at least the row max (min height), instead of the - // intrinsic query IntrinsicHeight would run, - // intrinsic query. 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 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 + // min (never tight) height leaves no residual RenderFlex overflow (#141). rows.add(WindEqualHeightRow(spacing: gapX, children: rowChildren)); } diff --git a/lib/src/widgets/wind_equal_height_row.dart b/lib/src/widgets/wind_equal_height_row.dart index f07ccd5..275c121 100644 --- a/lib/src/widgets/wind_equal_height_row.dart +++ b/lib/src/widgets/wind_equal_height_row.dart @@ -11,9 +11,12 @@ import 'package:flutter/widgets.dart'; /// 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 tight to the row's -/// max height. Real layout is exactly what `LayoutBuilder` supports, so a -/// `flex flex-col` cell stretches without asserting. +/// 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 @@ -110,17 +113,14 @@ class _RenderEqualHeightRow extends RenderBox final double target = maxHeight.clamp(constraints.minHeight, constraints.maxHeight); - // Pass 2: stretch every cell to at least `target` using a MIN height (never - // a tight height) and position it. A tight height would squeeze a cell - // whose content, laid out for real, needs a hair more than the - // loose-measured max (sub-pixel text/flex rounding) and produce a + // Pass 2: re-lay each cell with a MIN height of `target` (never a tight + // height) to settle the true row height. A tight height would squeeze a + // cell whose content, laid out for real, needs a hair more than the + // loose-measured max (sub-pixel text/flex rounding), producing a // "RenderFlex overflowed by ~2px" warning (#141). A min constraint instead - // lets such a cell grow, so the re-laid height is always >= the cell's own - // content and overflow is impossible. The row takes the tallest resulting - // height; in the common case every cell settles at `target` (equal), and in - // the rare sub-pixel case a grown cell simply sets a marginally taller row. + // lets such a cell keep its own (larger) height, so `rowHeight` ends up >= + // every cell's content and no cell is ever squeezed. double rowHeight = target; - double dx = 0; child = firstChild; while (child != null) { child.layout( @@ -133,6 +133,25 @@ class _RenderEqualHeightRow extends RenderBox parentUsesSize: true, ); if (child.size.height > rowHeight) rowHeight = child.size.height; + child = childAfter(child); + } + rowHeight = rowHeight.clamp(constraints.minHeight, constraints.maxHeight); + + // Pass 3: lay every cell to the settled `rowHeight` (still a MIN height, so + // no squeeze) and position it. This equalizes any cell that measured shorter + // than a sibling which grew in pass 2, so all cells share the row height. + double dx = 0; + child = firstChild; + while (child != null) { + child.layout( + BoxConstraints( + minWidth: cellWidth, + maxWidth: cellWidth, + minHeight: rowHeight, + maxHeight: constraints.maxHeight, + ), + parentUsesSize: true, + ); (child.parentData as _EqualHeightParentData).offset = Offset(dx, 0); dx += cellWidth + _spacing; child = childAfter(child);