From 043e223655441ad7f88693735dccb690cff8c3d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Thu, 20 Aug 2026 23:43:33 +0300 Subject: [PATCH 1/4] fix(flex): let a growing child keep the free space in a justify-between row A `justify-between` Row wrapped every child in `Flexible`, which is an approximation of the CSS `flex-shrink: 1` default and gives each child an EQUAL share of the free space. That share starves a sibling which asked for the space explicitly: in CSS a `flex: 1` child absorbs the leftover and the others stay at their content width (`flex: 0 1 auto` shrinks on overflow but never grows). Measured on a two-child page header at 402pt: the row handed 185pt to a `flex-1` title column and 185pt to an icon column that painted 24pt of it, so the title column, a loose flex child with no leftover to take, laid out at ZERO width while 140pt of the row sat blank beside a status badge. The wrap now only happens when no child claims a grow share. `overflow-hidden` keeps its wrap unconditionally, since that token asks for shrinking on purpose. `_claimsGrowShare` is narrower than `_selfWrapsInFlex` deliberately: the shrink-only tokens (`shrink`, `flex-shrink`, `flex-initial`) self-wrap to shrink and leave the free space alone, while `flex-auto` (CSS `flex: 1 1 auto`) grows and counts. --- lib/src/widgets/w_div.dart | 41 ++++++++- .../flex/justify_between_flex_child_test.dart | 83 +++++++++++++++++++ 2 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 test/flex/justify_between_flex_child_test.dart diff --git a/lib/src/widgets/w_div.dart b/lib/src/widgets/w_div.dart index b6ebed7..021fe85 100644 --- a/lib/src/widgets/w_div.dart +++ b/lib/src/widgets/w_div.dart @@ -662,8 +662,21 @@ class WDiv extends StatelessWidget { // For Row with space distribution OR overflow-hidden, wrap children with Flexible // This mimics CSS flex-shrink: 1 default behavior. Inside a horizontally // scrollable row, `Flexible` is also invalid, skip it too. + // A child that claims a GROW share (`flex-1`, `grow`, `flex-auto`, or a + // raw Expanded/Flexible) absorbs the free space, and in CSS its siblings + // then keep their content width: `flex: 0 1 auto` shrinks on overflow but + // never takes a share. Wrapping those siblings in `Flexible` hands each an + // EQUAL share instead, so a two-child `justify-between` row divided its + // width 50/50 and the child that asked for the space was capped at half + // the row: a 24pt icon column reserved 185pt of a 402pt header and the + // title column beside it measured ZERO. There is also nothing left for + // space distribution to distribute once a child grows, so the wrap here is + // only ever about shrinking, which `overflow-hidden` still asks for + // explicitly and keeps. + final bool hasGrowingChild = basisChildren.any(_claimsGrowShare); final needsFlexible = - (needsSpaceDistribution || hasOverflowClip) && !isMainAxisScrollable; + ((needsSpaceDistribution && !hasGrowingChild) || hasOverflowClip) && + !isMainAxisScrollable; // A Row hands non-flex children an UNBOUNDED main-axis constraint, so a // direct child carrying `w-full` (-> SizedBox(width: infinity)) asserts // "RenderBox was not laid out". Treat a bare `w-full` child as flex-1: @@ -963,6 +976,32 @@ class WDiv extends StatelessWidget { /// `hover:flex-1` are caught too. `grow-0`, `shrink-0`, and `flex-none` are /// deliberately NOT self-wrapping (they keep intrinsic main size without a /// `Flexible`), so they are absent here. + /// Whether [child] takes a share of the row's free space. + /// + /// Narrower than [_selfWrapsInFlex]: the shrink-only tokens (`shrink`, + /// `flex-shrink`, `flex-initial` = CSS `flex: 0 1 auto`) self-wrap in a + /// `Flexible` to shrink on overflow but never grow, so they leave the free + /// space to a sibling. `flex-auto` (CSS `flex: 1 1 auto`) does grow and counts. + static bool _claimsGrowShare(Widget child) { + if (child is Expanded || child is Flexible) return true; + + final String? className = _extractChildClassName(child); + if (className == null || className.isEmpty) return false; + + for (final raw in className.split(' ')) { + if (raw.isEmpty) continue; + final token = raw.contains(':') ? raw.split(':').last : raw; + if (token == 'grow' || + token == 'flex-grow' || + token == 'flex-auto' || + _numericFlexRegex.hasMatch(token)) { + return true; + } + } + + return false; + } + static bool _selfWrapsInFlex(String? className) { if (className == null || className.isEmpty) return false; for (final raw in className.split(' ')) { diff --git a/test/flex/justify_between_flex_child_test.dart b/test/flex/justify_between_flex_child_test.dart new file mode 100644 index 0000000..aad8472 --- /dev/null +++ b/test/flex/justify_between_flex_child_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_wind/fluttersdk_wind.dart'; + +/// Tests that a `justify-between` row honors an explicit `flex-1` child. +/// +/// In CSS, `justify-content: space-between` does NOT make items flexible: the +/// free space is distributed BETWEEN them and each item keeps its content +/// size (`flex: 0 1 auto`, shrinking only on overflow). A child that declares +/// `flex: 1` absorbs the free space; its siblings stay at their content width. +/// +/// Wind approximates the shrink half of that default by wrapping children in +/// `Flexible`, which is fine on its own but gives every child an equal flex +/// share. That share starves a sibling that asked for the space explicitly, so +/// a row with a `flex-1` column and a small icon column splits 50/50 and the +/// column that should have grown is capped at half the row. +void main() { + /// Pumps [child] inside a fixed-width Wind surface. + Future pumpAt(WidgetTester tester, double width, Widget child) { + return tester.pumpWidget( + MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Align( + alignment: Alignment.topLeft, + child: SizedBox(width: width, child: child), + ), + ), + ), + ); + } + + testWidgets( + 'justify-between gives a flex-1 child the whole free space', + (tester) async { + await pumpAt( + tester, + 400, + const WDiv( + className: 'flex flex-row items-center justify-between', + children: [ + WDiv( + key: Key('grower'), + className: 'flex-1', + child: SizedBox(height: 20), + ), + WDiv( + key: Key('icon'), + child: SizedBox(width: 24, height: 24), + ), + ], + ), + ); + + expect(tester.getSize(find.byKey(const Key('icon'))).width, 24); + expect( + tester.getSize(find.byKey(const Key('grower'))).width, + 400 - 24, + reason: 'the sibling keeps its content width, so everything left over ' + 'belongs to the child that asked for it', + ); + }, + ); + + testWidgets( + 'justify-between still shrinks siblings when nobody claims flex', + (tester) async { + await pumpAt( + tester, + 100, + const WDiv( + className: 'flex flex-row items-center justify-between', + children: [ + WText('Label', className: 'text-sm'), + WText('Very Long Value Text', className: 'text-sm'), + ], + ), + ); + + expect(tester.takeException(), isNull); + }, + ); +} From 64424023b03de7512dbe68f24fec338d019e56d2 Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Fri, 21 Aug 2026 13:10:42 +0300 Subject: [PATCH 2/4] fix(flex): count a bare w-full row child as a grow claim `doc/layout/flexbox.md` documents a bare `w-full` on a row child as filling the row "exactly like flex-1", and the Row composer implements that by wrapping the child in `Expanded`. The grow-share detection did not know about it, so the two diverged the moment the row also carried a `justify-*` token: measured in a 400pt `justify-between` row beside a 24pt icon, the `flex-1` child took 376 and the `w-full` child took 200. Also moves `_claimsGrowShare` below `_selfWrapsInFlex`. Inserted above it, the new method swallowed `_selfWrapsInFlex`'s doc comment, so the "makes it self-wrap in Expanded/Flexible" paragraph documented the wrong symbol and `_selfWrapsInFlex` was left with none. The token scan splits on `\s+` rather than a single space, matching `_hasBareFullWidth`, because this repo's own className convention is a triple-quoted string with one concern per line. --- lib/src/widgets/w_div.dart | 61 +++++++++++-------- .../flex/justify_between_flex_child_test.dart | 31 ++++++++++ 2 files changed, 66 insertions(+), 26 deletions(-) diff --git a/lib/src/widgets/w_div.dart b/lib/src/widgets/w_div.dart index 021fe85..e55d002 100644 --- a/lib/src/widgets/w_div.dart +++ b/lib/src/widgets/w_div.dart @@ -976,32 +976,6 @@ class WDiv extends StatelessWidget { /// `hover:flex-1` are caught too. `grow-0`, `shrink-0`, and `flex-none` are /// deliberately NOT self-wrapping (they keep intrinsic main size without a /// `Flexible`), so they are absent here. - /// Whether [child] takes a share of the row's free space. - /// - /// Narrower than [_selfWrapsInFlex]: the shrink-only tokens (`shrink`, - /// `flex-shrink`, `flex-initial` = CSS `flex: 0 1 auto`) self-wrap in a - /// `Flexible` to shrink on overflow but never grow, so they leave the free - /// space to a sibling. `flex-auto` (CSS `flex: 1 1 auto`) does grow and counts. - static bool _claimsGrowShare(Widget child) { - if (child is Expanded || child is Flexible) return true; - - final String? className = _extractChildClassName(child); - if (className == null || className.isEmpty) return false; - - for (final raw in className.split(' ')) { - if (raw.isEmpty) continue; - final token = raw.contains(':') ? raw.split(':').last : raw; - if (token == 'grow' || - token == 'flex-grow' || - token == 'flex-auto' || - _numericFlexRegex.hasMatch(token)) { - return true; - } - } - - return false; - } - static bool _selfWrapsInFlex(String? className) { if (className == null || className.isEmpty) return false; for (final raw in className.split(' ')) { @@ -1035,6 +1009,41 @@ class WDiv extends StatelessWidget { return false; } + /// Whether [child] takes a share of the row's free space. + /// + /// Narrower than [_selfWrapsInFlex]: the shrink-only tokens (`shrink`, + /// `flex-shrink`, `flex-initial` = CSS `flex: 0 1 auto`) self-wrap in a + /// `Flexible` to shrink on overflow but never grow, so they leave the free + /// space to a sibling. `flex-auto` (CSS `flex: 1 1 auto`) does grow and counts. + /// + /// A bare `w-full` counts too, because the Row composer above turns exactly + /// that child into an `Expanded`. Leaving it out would starve it at half the + /// row while `flex-1` took the whole remainder, and the two are documented as + /// equivalent on a row child. + static bool _claimsGrowShare(Widget child) { + if (child is Expanded || child is Flexible) return true; + + final String? className = _extractChildClassName(child); + if (className == null || className.isEmpty) return false; + + if (_hasBareFullWidth(className) && !_selfWrapsInFlex(className)) { + return true; + } + + for (final raw in className.split(RegExp(r'\s+'))) { + if (raw.isEmpty) continue; + final token = raw.contains(':') ? raw.split(':').last : raw; + if (token == 'grow' || + token == 'flex-grow' || + token == 'flex-auto' || + _numericFlexRegex.hasMatch(token)) { + return true; + } + } + + return false; + } + /// Extracts `className` from any Wind widget via dynamic access. static String? _extractChildClassName(Widget child) { try { diff --git a/test/flex/justify_between_flex_child_test.dart b/test/flex/justify_between_flex_child_test.dart index aad8472..4209d66 100644 --- a/test/flex/justify_between_flex_child_test.dart +++ b/test/flex/justify_between_flex_child_test.dart @@ -62,6 +62,37 @@ void main() { }, ); + testWidgets( + 'justify-between gives a bare w-full child the whole free space', + (tester) async { + // The Row composer turns a bare `w-full` child into an `Expanded`, and + // `doc/layout/flexbox.md` documents it as filling the row "exactly like + // flex-1". Without w-full counted as a grow claim the two diverge here: + // the flex-1 case above measures 376 and this one measured 200. + await pumpAt( + tester, + 400, + const WDiv( + className: 'flex flex-row items-center justify-between', + children: [ + WDiv( + key: Key('grower'), + className: 'w-full', + child: SizedBox(height: 20), + ), + WDiv( + key: Key('icon'), + child: SizedBox(width: 24, height: 24), + ), + ], + ), + ); + + expect(tester.getSize(find.byKey(const Key('icon'))).width, 24); + expect(tester.getSize(find.byKey(const Key('grower'))).width, 400 - 24); + }, + ); + testWidgets( 'justify-between still shrinks siblings when nobody claims flex', (tester) async { From 610d04414c2d655a48b7879c26a74b069d8ac461 Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Fri, 21 Aug 2026 13:10:47 +0300 Subject: [PATCH 3/4] docs(flex): sync the grow-share rule across doc, skill and changelog `doc/widgets/w-div.md` claimed automatic `Flexible` wrapping for row children with no qualification, which is the behaviour this branch changes. `doc/layout/flexbox.md` and the skill's layout rules said nothing about what happens when a child grows, so the 50/50 split read as intended behaviour rather than the bug it was. Skill version 2.12.0: className layout semantics changed, so the distribution to fluttersdk/ai needs to carry it. --- CHANGELOG.md | 4 ++++ doc/layout/flexbox.md | 2 ++ doc/widgets/w-div.md | 2 +- skills/wind-ui/SKILL.md | 5 +++-- skills/wind-ui/references/layouts.md | 4 +++- 5 files changed, 13 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index edae21a..b01034b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0. - **`skills/wind-ui/references/design-culture.md`: the taste layer the skill never had.** Every other reference answers "does this token exist and what does it do"; nothing answered "which token should this be", so an agent handed a screen with no design spec picked plausible values and produced work that rendered correctly and looked wrong. The new file carries the three-level hierarchy with the type scale that implements it, the semantic / status / dark-surface color pair tables (routed through the seeded `primary` token rather than a literal `blue-600`), the spacing scale with the touch-target floors, HSL palette construction, the depth scale plus the border-free alternatives, mobile form / loading / empty / feedback / list patterns, the iOS navigation and gesture contracts, and a 14-row anti-pattern wall. It came from the distribution repo (`fluttersdk/ai`), which is a mirror: content that only lived there was one `rsync --delete` away from disappearing, and no wind consumer ever received it. Wired into SKILL.md section 11 and the section 14 reference table. Skill version 2.11.0. (`skills/wind-ui/references/design-culture.md`, `skills/wind-ui/SKILL.md`) +### Fixed + +- **A `justify-between` row starved the one child that asked for the space.** Space distribution wrapped every child in a `Flexible` to reproduce the CSS `flex: 0 1 auto` shrink default, but Flutter splits free space equally between flex children, so the wrap also handed a share to siblings that never asked for one. Measured on a two-child page header at 402pt: the row gave 185pt to a `flex-1` title column and 185pt to an icon column that painted 24pt of it, and the title column, a loose flex child with no leftover left to take, laid out at ZERO width while 140pt of the row sat blank. A grow claim on any child (`flex-1`, `flex-{n}`, `grow`, `flex-grow`, `flex-auto`, a bare `w-full`, or a raw `Expanded` / `Flexible`) now turns the wrap off for the row: the growing child takes the whole remainder and its siblings keep their content width, which is what `justify-content: space-between` does in CSS, where the free space is distributed BETWEEN items rather than made flexible. There is nothing left to distribute once a child grows, so the wrap was only ever about shrinking: `overflow-hidden` keeps it unconditionally because that token asks for shrinking on purpose, and the shrink-only tokens (`shrink`, `flex-shrink`, `flex-initial`, CSS `flex: 0 1 auto`) still self-wrap to shrink without counting as a claim. A bare `w-full` counts because the Row composer already turns exactly that child into an `Expanded`; leaving it out would have capped it at half the row while `flex-1` took the remainder, and the two are documented as equivalent on a row child. (`lib/src/widgets/w_div.dart`, `doc/layout/flexbox.md`, `doc/widgets/w-div.md`, `skills/wind-ui/SKILL.md`, `skills/wind-ui/references/layouts.md`) + ### Quality - **48 branches had accumulated, 45 of them PRs that landed months ago.** `delete_branch_on_merge` was off, so every task branch outlived its merge and the list grew by one per PR since December 2025. It is on now, which handles everything from here without a workflow, a token or a cron: the setting fires on the merge event alone, touches only that PR's head branch, and cannot reach `master` or `v0` because both are protected. The 46 leftovers (45 merged, plus a branch from the abandoned release-please setup whose PR #80 was closed unmerged) are deleted. The tempting alternative, a scheduled stale-branch job, was rejected: with the setting on it would only ever catch branches that never merged, this repo has produced exactly one of those in its history, and its "untouched for N days" test cannot tell an abandoned branch from one you set down for a fortnight. A merge is a statement of intent; a date is not. Recorded in `CLAUDE.md` under Branching, because a policy nobody wrote down is not a policy. diff --git a/doc/layout/flexbox.md b/doc/layout/flexbox.md index 88faa0d..5be9e93 100644 --- a/doc/layout/flexbox.md +++ b/doc/layout/flexbox.md @@ -190,6 +190,8 @@ WDiv( ) ``` +> **A child that grows turns the automatic `Flexible` wrap off.** Space distribution wraps each row child in a `Flexible` so it can shrink, mirroring the CSS `flex: 0 1 auto` default. Flutter shares free space equally between flex children, though, so that wrap also hands a share to a sibling that never asked for one: a `justify-between` row holding a `flex-1` title and a 24 px icon split 400 px down the middle, and the title rendered at 200 px with 176 px sitting blank beside the icon. So when any child claims a grow share (`flex-1`, `flex-{n}`, `grow`, `flex-grow`, `flex-auto`, a bare `w-full`, or a raw `Expanded` / `Flexible`), the wrap is skipped and the growing child takes the whole remainder while its siblings keep their content width, which is what CSS `justify-content: space-between` does. There is nothing left to distribute once a child grows, so the wrap was only ever about shrinking. Two exceptions: `overflow-hidden` keeps the wrap unconditionally because that token asks for shrinking on purpose, and the shrink-only tokens (`shrink`, `flex-shrink`, `flex-initial`) are not a grow claim. + ## Align Items diff --git a/doc/widgets/w-div.md b/doc/widgets/w-div.md index f21af83..77a9776 100644 --- a/doc/widgets/w-div.md +++ b/doc/widgets/w-div.md @@ -104,7 +104,7 @@ Precedence: inline `backgroundColor` wins over any `bg-*` / `dark:bg-*` resolved `WDiv` dynamically switches its internal structure based on the `display` utility classes provided in `className`. - **Block (Default)**: Standard vertical stack or single child wrapper. If `children` is used without `flex` or `grid`, it defaults to a `Column`. -- **Flex**: Enabled via `flex`. Supports `flex-row`, `flex-col`, `gap-*`, `items-*`, `justify-*`. It mimics CSS Flexbox behavior, including automatic `Flexible` wrapping for children in rows. +- **Flex**: Enabled via `flex`. Supports `flex-row`, `flex-col`, `gap-*`, `items-*`, `justify-*`. It mimics CSS Flexbox behavior, including automatic `Flexible` wrapping for row children so they can shrink. That wrap is skipped when a child claims a grow share (`flex-1`, `grow`, `flex-auto`, a bare `w-full`, or a raw `Expanded`/`Flexible`), so the growing child keeps the free space and its siblings stay at their content width. See [Flexbox](../layout/flexbox.md#flex-grow--shrink). - **Grid**: Enabled via `grid`. Uses a combination of `Wrap` and `LayoutBuilder` to achieve Tailwind-like grid behavior (`grid-cols-*`) with intrinsic item heights. - **Wrap**: Enabled via `wrap`. Elements wrap to the next line when space is insufficient, similar to `flex-wrap` in CSS. - **Hidden**: Enabled via `hidden`. The widget short-circuits to `SizedBox.shrink()` to save resources. diff --git a/skills/wind-ui/SKILL.md b/skills/wind-ui/SKILL.md index 40e56ac..62e9389 100644 --- a/skills/wind-ui/SKILL.md +++ b/skills/wind-ui/SKILL.md @@ -2,10 +2,10 @@ name: wind-ui description: "fluttersdk_wind 1.3: 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.11.0 +version: 2.12.0 --- - + # Wind UI 1.3 @@ -219,6 +219,7 @@ Wind hides most boilerplate but never changes Flutter's "constraints down, sizes | Rule | Wrong | Right | |---|---|---| | **Row children: prefer `flex-1`** (a bare `w-full` now also works, treated as `flex-1`) | n/a | `WDiv(className: 'flex flex-row', children: [WDiv(className: 'flex-1', ...)])` | +| **A grow claim turns off `justify-*`'s shrink wrap** (so `flex-1` keeps the whole remainder, siblings stay at content width; `overflow-hidden` still wraps) | expecting `justify-between` to split the row evenly between a `flex-1` child and a 24 dp icon | `WDiv(className: 'flex flex-row justify-between', children: [WDiv(className: 'flex-1', ...), WIcon(...)])` | | **Scrollable children use `flex-1`, not `h-full`** | `WDiv(className: 'flex flex-col', children: [WDiv(className: 'overflow-y-auto h-full', ...)])` → unbounded height | `WDiv(className: 'flex flex-col h-full', children: [WDiv(className: 'flex-1 overflow-y-auto', scrollPrimary: true, ...)])` | | **`absolute` requires `relative` parent** | `WDiv(className: 'flex', children: [..., WDiv(className: 'absolute top-0 right-0')])` does not position correctly | `WDiv(className: 'relative flex', children: [..., WDiv(className: 'absolute top-0 right-0')])` | | **`truncate` requires bounded width** | `WText('long...', className: 'truncate')` inside a Row | wrap in `WDiv(className: 'flex-1', child: WText(..., className: 'truncate'))` | diff --git a/skills/wind-ui/references/layouts.md b/skills/wind-ui/references/layouts.md index 8096f23..5885f55 100644 --- a/skills/wind-ui/references/layouts.md +++ b/skills/wind-ui/references/layouts.md @@ -22,7 +22,7 @@ Every `RenderBox` receives a `BoxConstraints` (`minWidth`, `maxWidth`, `minHeight`, `maxHeight`) from its parent, lays out each child with derived constraints, then picks a `Size` that satisfies its own incoming constraints. The parent alone decides where in space each child goes. -Two consequences drive almost every Wind layout footgun: +Four consequences drive almost every Wind layout footgun: 1. **`Row` and `Column` pass UNBOUNDED constraints to non-flex children in step 1.** A child that responds with `double.infinity` (e.g. `h-full` inside a Column inside a scroll) blows up the parent's bounded layout. `flex-1` is the canonical fix because it moves the child to step 2 (bounded share of remaining space). (A bare `w-full` on a direct Row child is auto-handled: Wind wraps it in `Expanded` so it behaves as `flex-1` instead of asserting; prefer `flex-1` for clarity.) @@ -30,6 +30,8 @@ Two consequences drive almost every Wind layout footgun: 3. **`flex flex-col` stretches `WDiv`, `WAnchor` (any child), and `WButton` children to the column width by default.** With NO explicit `items-*` token, each such child that does not control its own width is wrapped in `SizedBox(width: double.infinity)`, mirroring CSS `align-items: stretch`. For `WAnchor`: when the anchor wraps a `WDiv`, the inner `WDiv`'s className decides; when it wraps a `WText` or raw widget, it always stretches. 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`), 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 any `items-*` token (e.g. `items-start`) to disable the stretch and let children size to content. This is column-only; rows are never auto-stretched on the cross axis. +4. **`justify-*` on a row wraps every child in a `Flexible` so it can shrink, UNLESS a child claims a grow share.** Flutter splits free space equally between flex children, so the shrink wrap also hands a share to a sibling that never asked for one: a `justify-between` row holding a `flex-1` title next to a 24 px icon split 400 px down the middle and rendered the title at 200 px. A grow claim (`flex-1`, `flex-{n}`, `grow`, `flex-grow`, `flex-auto`, a bare `w-full`, or a raw `Expanded` / `Flexible`) therefore turns the wrap off for the whole row: the growing child takes the remainder, its siblings keep their content width, and that matches CSS `justify-content: space-between`. `overflow-hidden` keeps the wrap unconditionally (it asks for shrinking on purpose), and `shrink` / `flex-shrink` / `flex-initial` are not a grow claim. + Memorize these and the rest follows. --- From c1b7221207acc6b8e9f8934cd948edd10999f583 Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Fri, 21 Aug 2026 14:08:46 +0300 Subject: [PATCH 4/4] fix(flex): stop an inactive prefixed grow token speaking for the row CodeRabbit caught a regression this branch introduced. `_claimsGrowShare` stripped every variant prefix, so an inactive `hover:flex-1` or `md:flex-1` set `hasGrowingChild` and disabled the shrink wrap for the WHOLE row, while the conditional child never created its own `Expanded`. Measured in a 100pt `justify-between` row: a text sibling laid out at 504 and Flutter reported "A RenderFlex overflowed by 424 pixels on the right". With the prefix scan gone it lays out at 80, which is the pre-branch behaviour. The reviewer proposed threading `BuildContext` plus the child's states in and resolving each child's active style. That is correct in every case but runs a parse per child inside the row composition path, and it is not needed to close the defect: a prefixed token is conditional, so the honest answer from a class string alone is "no claim". That is already the documented policy for `md:w-full` in `_hasBareFullWidth`, for the same reason. The asymmetry with `_selfWrapsInFlex`, which stays prefix-agnostic, is deliberate and now documented: there a false positive only skips a wrap, while a false negative double-wraps and throws ParentDataWidget. Here the answer governs every sibling. The residual cost is that a prefixed grow token keeps the old equal-share split while its variant IS active, which is pre-existing behaviour rather than a new regression. Also adds the `WindParser.clearCache()` setUp the second review comment asked for, which `.claude/rules/tests.md` requires of any test pumping className-styled widgets. --- CHANGELOG.md | 2 +- doc/layout/flexbox.md | 2 +- lib/src/widgets/w_div.dart | 21 +++++++-- skills/wind-ui/references/layouts.md | 2 +- .../flex/justify_between_flex_child_test.dart | 43 +++++++++++++++++++ 5 files changed, 64 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d88391..b81e152 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0. ### Fixed -- **A `justify-between` row starved the one child that asked for the space.** Space distribution wrapped every child in a `Flexible` to reproduce the CSS `flex: 0 1 auto` shrink default, but Flutter splits free space equally between flex children, so the wrap also handed a share to siblings that never asked for one. Measured on a two-child page header at 402pt: the row gave 185pt to a `flex-1` title column and 185pt to an icon column that painted 24pt of it, and the title column, a loose flex child with no leftover left to take, laid out at ZERO width while 140pt of the row sat blank. A grow claim on any child (`flex-1`, `flex-{n}`, `grow`, `flex-grow`, `flex-auto`, a bare `w-full`, or a raw `Expanded` / `Flexible`) now turns the wrap off for the row: the growing child takes the whole remainder and its siblings keep their content width, which is what `justify-content: space-between` does in CSS, where the free space is distributed BETWEEN items rather than made flexible. There is nothing left to distribute once a child grows, so the wrap was only ever about shrinking: `overflow-hidden` keeps it unconditionally because that token asks for shrinking on purpose, and the shrink-only tokens (`shrink`, `flex-shrink`, `flex-initial`, CSS `flex: 0 1 auto`) still self-wrap to shrink without counting as a claim. A bare `w-full` counts because the Row composer already turns exactly that child into an `Expanded`; leaving it out would have capped it at half the row while `flex-1` took the remainder, and the two are documented as equivalent on a row child. (`lib/src/widgets/w_div.dart`, `doc/layout/flexbox.md`, `doc/widgets/w-div.md`, `skills/wind-ui/SKILL.md`, `skills/wind-ui/references/layouts.md`) +- **A `justify-between` row starved the one child that asked for the space.** Space distribution wrapped every child in a `Flexible` to reproduce the CSS `flex: 0 1 auto` shrink default, but Flutter splits free space equally between flex children, so the wrap also handed a share to siblings that never asked for one. Measured on a two-child page header at 402pt: the row gave 185pt to a `flex-1` title column and 185pt to an icon column that painted 24pt of it, and the title column, a loose flex child with no leftover left to take, laid out at ZERO width while 140pt of the row sat blank. A grow claim on any child (`flex-1`, `flex-{n}`, `grow`, `flex-grow`, `flex-auto`, a bare `w-full`, or a raw `Expanded` / `Flexible`) now turns the wrap off for the row: the growing child takes the whole remainder and its siblings keep their content width, which is what `justify-content: space-between` does in CSS, where the free space is distributed BETWEEN items rather than made flexible. There is nothing left to distribute once a child grows, so the wrap was only ever about shrinking: `overflow-hidden` keeps it unconditionally because that token asks for shrinking on purpose, and the shrink-only tokens (`shrink`, `flex-shrink`, `flex-initial`, CSS `flex: 0 1 auto`) still self-wrap to shrink without counting as a claim. A bare `w-full` counts because the Row composer already turns exactly that child into an `Expanded`; leaving it out would have capped it at half the row while `flex-1` took the remainder, and the two are documented as equivalent on a row child. A PREFIXED grow token does not count: `hover:flex-1` and `md:grow` are conditional and cannot be resolved from the class string, so counting one strips the shrink wrap off every sibling at a state or breakpoint where nothing actually grows, which measured a text sibling laying out at 504 in a 100pt row with `A RenderFlex overflowed by 424 pixels on the right`. That mirrors the policy `md:w-full` already had for the same reason, and it leaves a prefixed grow token on the pre-existing equal-share split while its variant is active rather than trading a starved child for an overflowing row. (`lib/src/widgets/w_div.dart`, `doc/layout/flexbox.md`, `doc/widgets/w-div.md`, `skills/wind-ui/SKILL.md`, `skills/wind-ui/references/layouts.md`) ### Quality diff --git a/doc/layout/flexbox.md b/doc/layout/flexbox.md index 5be9e93..76a59ac 100644 --- a/doc/layout/flexbox.md +++ b/doc/layout/flexbox.md @@ -190,7 +190,7 @@ WDiv( ) ``` -> **A child that grows turns the automatic `Flexible` wrap off.** Space distribution wraps each row child in a `Flexible` so it can shrink, mirroring the CSS `flex: 0 1 auto` default. Flutter shares free space equally between flex children, though, so that wrap also hands a share to a sibling that never asked for one: a `justify-between` row holding a `flex-1` title and a 24 px icon split 400 px down the middle, and the title rendered at 200 px with 176 px sitting blank beside the icon. So when any child claims a grow share (`flex-1`, `flex-{n}`, `grow`, `flex-grow`, `flex-auto`, a bare `w-full`, or a raw `Expanded` / `Flexible`), the wrap is skipped and the growing child takes the whole remainder while its siblings keep their content width, which is what CSS `justify-content: space-between` does. There is nothing left to distribute once a child grows, so the wrap was only ever about shrinking. Two exceptions: `overflow-hidden` keeps the wrap unconditionally because that token asks for shrinking on purpose, and the shrink-only tokens (`shrink`, `flex-shrink`, `flex-initial`) are not a grow claim. +> **A child that grows turns the automatic `Flexible` wrap off.** Space distribution wraps each row child in a `Flexible` so it can shrink, mirroring the CSS `flex: 0 1 auto` default. Flutter shares free space equally between flex children, though, so that wrap also hands a share to a sibling that never asked for one: a `justify-between` row holding a `flex-1` title and a 24 px icon split 400 px down the middle, and the title rendered at 200 px with 176 px sitting blank beside the icon. So when any child claims a grow share (`flex-1`, `flex-{n}`, `grow`, `flex-grow`, `flex-auto`, a bare `w-full`, or a raw `Expanded` / `Flexible`), the wrap is skipped and the growing child takes the whole remainder while its siblings keep their content width, which is what CSS `justify-content: space-between` does. There is nothing left to distribute once a child grows, so the wrap was only ever about shrinking. Three exceptions: `overflow-hidden` keeps the wrap unconditionally because that token asks for shrinking on purpose, the shrink-only tokens (`shrink`, `flex-shrink`, `flex-initial`) are not a grow claim, and a PREFIXED grow token (`hover:flex-1`, `md:grow`) is not one either. A prefixed token is conditional, and the row cannot tell from the class string whether the variant is active, so treating it as a claim would strip the shrink wrap off every sibling at a breakpoint where nothing grows. Use an unprefixed `flex-1` when you want the child to own the remainder. ## Align Items diff --git a/lib/src/widgets/w_div.dart b/lib/src/widgets/w_div.dart index e55d002..c8ce8a5 100644 --- a/lib/src/widgets/w_div.dart +++ b/lib/src/widgets/w_div.dart @@ -1020,6 +1020,22 @@ class WDiv extends StatelessWidget { /// that child into an `Expanded`. Leaving it out would starve it at half the /// row while `flex-1` took the whole remainder, and the two are documented as /// equivalent on a row child. + /// + /// Unlike [_selfWrapsInFlex] this scan is NOT prefix-agnostic, and the + /// asymmetry is the point. There a false positive is the safe direction: it + /// only skips a wrap, while a false negative double-wraps and throws + /// "Incorrect use of ParentDataWidget". Here the answer governs the WHOLE + /// row, so counting an inactive `hover:flex-1` or `md:flex-1` would strip the + /// shrink wrap off every sibling at a breakpoint where nothing actually + /// grows: measured in a 100pt row, a text sibling went from a 50pt share to + /// 80pt because a hover variant that was not active had spoken for the row. + /// A prefixed token is conditional and cannot be resolved from the class + /// string alone, so it does not claim, exactly as [_hasBareFullWidth] + /// deliberately ignores `md:w-full`. The cost is that a prefixed grow token + /// keeps the old equal-share split while its variant IS active; that is the + /// pre-existing behaviour rather than a new regression, and the conservative + /// direction when the alternative is removing shrink protection from + /// siblings that never asked for it. static bool _claimsGrowShare(Widget child) { if (child is Expanded || child is Flexible) return true; @@ -1030,9 +1046,8 @@ class WDiv extends StatelessWidget { return true; } - for (final raw in className.split(RegExp(r'\s+'))) { - if (raw.isEmpty) continue; - final token = raw.contains(':') ? raw.split(':').last : raw; + for (final token in className.split(RegExp(r'\s+'))) { + if (token.isEmpty || token.contains(':')) continue; if (token == 'grow' || token == 'flex-grow' || token == 'flex-auto' || diff --git a/skills/wind-ui/references/layouts.md b/skills/wind-ui/references/layouts.md index 5885f55..5e464aa 100644 --- a/skills/wind-ui/references/layouts.md +++ b/skills/wind-ui/references/layouts.md @@ -30,7 +30,7 @@ Four consequences drive almost every Wind layout footgun: 3. **`flex flex-col` stretches `WDiv`, `WAnchor` (any child), and `WButton` children to the column width by default.** With NO explicit `items-*` token, each such child that does not control its own width is wrapped in `SizedBox(width: double.infinity)`, mirroring CSS `align-items: stretch`. For `WAnchor`: when the anchor wraps a `WDiv`, the inner `WDiv`'s className decides; when it wraps a `WText` or raw widget, it always stretches. 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`), 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 any `items-*` token (e.g. `items-start`) to disable the stretch and let children size to content. This is column-only; rows are never auto-stretched on the cross axis. -4. **`justify-*` on a row wraps every child in a `Flexible` so it can shrink, UNLESS a child claims a grow share.** Flutter splits free space equally between flex children, so the shrink wrap also hands a share to a sibling that never asked for one: a `justify-between` row holding a `flex-1` title next to a 24 px icon split 400 px down the middle and rendered the title at 200 px. A grow claim (`flex-1`, `flex-{n}`, `grow`, `flex-grow`, `flex-auto`, a bare `w-full`, or a raw `Expanded` / `Flexible`) therefore turns the wrap off for the whole row: the growing child takes the remainder, its siblings keep their content width, and that matches CSS `justify-content: space-between`. `overflow-hidden` keeps the wrap unconditionally (it asks for shrinking on purpose), and `shrink` / `flex-shrink` / `flex-initial` are not a grow claim. +4. **`justify-*` on a row wraps every child in a `Flexible` so it can shrink, UNLESS a child claims a grow share.** Flutter splits free space equally between flex children, so the shrink wrap also hands a share to a sibling that never asked for one: a `justify-between` row holding a `flex-1` title next to a 24 px icon split 400 px down the middle and rendered the title at 200 px. A grow claim (`flex-1`, `flex-{n}`, `grow`, `flex-grow`, `flex-auto`, a bare `w-full`, or a raw `Expanded` / `Flexible`) therefore turns the wrap off for the whole row: the growing child takes the remainder, its siblings keep their content width, and that matches CSS `justify-content: space-between`. `overflow-hidden` keeps the wrap unconditionally (it asks for shrinking on purpose), `shrink` / `flex-shrink` / `flex-initial` are not a grow claim, and neither is a PREFIXED grow token: `hover:flex-1` and `md:grow` are conditional, so they never speak for the row. Reach for an unprefixed `flex-1` when you want the child to own the remainder. Memorize these and the rest follows. diff --git a/test/flex/justify_between_flex_child_test.dart b/test/flex/justify_between_flex_child_test.dart index 4209d66..02b3be7 100644 --- a/test/flex/justify_between_flex_child_test.dart +++ b/test/flex/justify_between_flex_child_test.dart @@ -15,6 +15,10 @@ import 'package:fluttersdk_wind/fluttersdk_wind.dart'; /// a row with a `flex-1` column and a small icon column splits 50/50 and the /// column that should have grown is capped at half the row. void main() { + setUp(() { + WindParser.clearCache(); + }); + /// Pumps [child] inside a fixed-width Wind surface. Future pumpAt(WidgetTester tester, double width, Widget child) { return tester.pumpWidget( @@ -93,6 +97,45 @@ void main() { }, ); + testWidgets( + 'an inactive prefixed grow token does not speak for the row', + (tester) async { + // A prefixed token is conditional, so it cannot be resolved from the + // class string alone. Counting it strips the shrink wrap off every + // sibling at a breakpoint or state where nothing actually grows: with + // `hover:flex-1` claiming the row while the hover state was inactive, + // this text laid out at 504 in a 100pt row and Flutter reported + // "A RenderFlex overflowed by 424 pixels on the right". + await pumpAt( + tester, + 100, + const WDiv( + className: 'flex flex-row items-center justify-between', + children: [ + WDiv( + key: Key('conditional'), + className: 'hover:flex-1', + child: SizedBox(width: 20, height: 20), + ), + WText( + key: Key('long'), + 'Very Long Value Text That Cannot Fit', + className: 'text-sm', + ), + ], + ), + ); + + expect( + tester.getSize(find.byKey(const Key('long'))).width, + 100 - 20, + reason: 'the shrink wrap stays on while the hover variant is inactive, ' + 'so the text shrinks into the row instead of overflowing it', + ); + expect(tester.takeException(), isNull); + }, + ); + testWidgets( 'justify-between still shrinks siblings when nobody claims flex', (tester) async {