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); + }, + ); +}