diff --git a/CHANGELOG.md b/CHANGELOG.md
index afc1352..b81e152 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. 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
- **`Lint & Test` was red on every open PR, and not one of them had broken anything.** The Flutter tool ships an `analysis_options.yaml` migrator that appends an `analyzer.exclude` block for `build/` and the six platform runner directories, and it runs on every `flutter pub get`. CI's first step after checkout is `flutter pub get`, so by the time `dart pub publish --dry-run` ran seven steps later the checkout was dirty, the dry-run reported `1 checked-in file is modified in git`, and it exits 65 on a warning. Every gate before it was green; the failure was the toolchain editing the repo mid-run. That is the worst shape a red check can take, because it fails identically on a workflow-only Dependabot bump and on a real regression, so the signal stops carrying information. Both files now carry the block the migrator wants, which makes the migrator a no-op and the checkout clean. The alternative, reverting the file inside the workflow before the dry-run, was rejected: it would leave every contributor's tree dirty after a `pub get` and hide the drift instead of settling it. The excludes are also correct on their own terms, since none of those directories hold hand-written Dart. (`analysis_options.yaml`, `example/analysis_options.yaml`)
diff --git a/doc/layout/flexbox.md b/doc/layout/flexbox.md
index 88faa0d..76a59ac 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. 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/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/lib/src/widgets/w_div.dart b/lib/src/widgets/w_div.dart
index b6ebed7..c8ce8a5 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:
@@ -996,6 +1009,56 @@ 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.
+ ///
+ /// 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;
+
+ final String? className = _extractChildClassName(child);
+ if (className == null || className.isEmpty) return false;
+
+ if (_hasBareFullWidth(className) && !_selfWrapsInFlex(className)) {
+ return true;
+ }
+
+ for (final token in className.split(RegExp(r'\s+'))) {
+ if (token.isEmpty || token.contains(':')) continue;
+ 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/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..5e464aa 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), `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
new file mode 100644
index 0000000..02b3be7
--- /dev/null
+++ b/test/flex/justify_between_flex_child_test.dart
@@ -0,0 +1,157 @@
+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() {
+ setUp(() {
+ WindParser.clearCache();
+ });
+
+ /// 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 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(
+ '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 {
+ 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);
+ },
+ );
+}