diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0ac48ea..cbb322e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -32,7 +32,7 @@ First stable release. wind is utility-first, Tailwind-syntax styling for Flutter
- **Reverse flex direction**: `flex-row-reverse` and `flex-col-reverse` flip the main-axis direction so `justify-start` mirrors per CSS semantics.
- **`self-*` align-self shorthand**: `self-start`, `self-end`, `self-center`, `self-stretch`, `self-auto`, `self-baseline` as aliases for the `align-self-*` long form, matching Tailwind's canonical class name.
- **Flex grow / basis tokens**: `grow` (Tailwind shorthand for `flex-grow`, i.e. `flex: 1`), `grow-0` (no grow), and `basis-1/2` / `basis-1/3` / `basis-1/4` / `basis-full` / `basis-[Npx]`. Basis approximates CSS `flex-basis`: it sets the child's initial MAIN-axis size (width in a row, height in a column) and ignores grow/shrink interplay. The no-grow / no-shrink resets follow last-class-wins: `grow-0` cancels an earlier `grow`/`flex-grow`/`flex-N`, `shrink-0` cancels an earlier `shrink`/`flex-shrink`, and `flex-none` (`flex: 0 0 auto`) cancels both, within the same active class list.
-- **Smart column cross-axis stretch**: a `flex flex-col` with no explicit `items-*` token now stretches each `WDiv` child that does not control its own width to the column width (CSS `align-items: stretch` default). Children that self-wrap in `Expanded`/`Flexible` (`grow`, `flex-grow`, `flex-auto`, `flex-initial`, `shrink`, `flex-shrink`, `flex-N`, in any state/breakpoint variant), children with an explicit `w-*` / `min-w-*` / `max-w-*` (including `w-full`), absolute children, and non-`WDiv` children are left untouched. `shrink-0` / `flex-none` children still stretch on the cross axis (their no-shrink effect is main-axis only, matching CSS). Rows are unaffected.
+- **Smart column cross-axis stretch**: a `flex flex-col` with no explicit `items-*` token now stretches each `WDiv`, `WAnchor` (any child), and `WButton` child that does not control its own width to the column width (CSS `align-items: stretch` default), so a clickable nav row (`WAnchor(onTap) > WDiv`) fills the column width without an explicit `items-stretch` or `w-full`. For a `WAnchor`: when it wraps a `WDiv`, the inner `WDiv`'s className decides; when it wraps a `WText` or raw widget, it always stretches. Children that self-wrap in `Expanded`/`Flexible` (`grow`, `flex-grow`, `flex-auto`, `flex-initial`, `shrink`, `flex-shrink`, `flex-N`, in any state/breakpoint variant), children with an explicit `w-*` / `min-w-*` / `max-w-*` (including `w-full`), absolute children, bare `WText` leaves, and raw Flutter widgets are left untouched. `shrink-0` / `flex-none` children still stretch on the cross axis (their no-shrink effect is main-axis only, matching CSS). Rows are unaffected.
- **Inline color props**: `WDiv.backgroundColor` and `WText.foregroundColor` for runtime-dynamic colors. Override any `bg-*` / `text-*` from className and stay out of the parser cache key.
- **`WBreakpoint` widget**: declarative per-breakpoint widget tree builder. Reach for it when className prefixes are not enough because the widget structure itself changes between breakpoints.
- **`WDynamic`**: JSON-driven widget tree renderer. 13 Wind types + 16 Flutter core types allowed by default, extensible via `builders:`, restrictable via `denyWidgets:`, with `customIcons:` for user-defined icon mappings (24 built-in glyphs). State binding by widget `id`, action dispatch via `WActionHandler`, max recursion depth 50.
diff --git a/doc/layout/flexbox.md b/doc/layout/flexbox.md
index 37e9242..575f854 100644
--- a/doc/layout/flexbox.md
+++ b/doc/layout/flexbox.md
@@ -208,7 +208,7 @@ Controls how children are distributed along the **cross axis** (Vertical for `ro
WDiv(className: 'flex items-center h-20')
```
-> **Column default — smart cross-axis stretch.** A `flex flex-col` with no explicit `items-*` token stretches each `WDiv` child that does not control its own width to the column width, matching CSS `align-items: stretch`. 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, and non-`WDiv` children. `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 turn this off and let children size to content. Rows are never auto-stretched on the cross axis. When the column itself sits in an unbounded-width context (a bare `Row` slot, `UnconstrainedBox`, horizontal scroll), the stretch safely falls back to content-sized children instead of forcing an infinite width.
+> **Column default: smart cross-axis stretch.** A `flex flex-col` with no explicit `items-*` token stretches each `WDiv`, `WAnchor` (any child), and `WButton` child that does not control its own width to the column width, matching CSS `align-items: stretch`. For `WAnchor`: when the anchor wraps a `WDiv`, the inner `WDiv`'s className decides (so `WAnchor > WDiv(w-32)` keeps 128 px; a `WAnchor > WDiv` with a self-flex token is excluded just as a direct self-flexing `WDiv` is); when the anchor wraps a `WText` or raw widget, the anchor stretches by policy so its tap surface fills the column. Left untouched: children with an explicit width (`w-*` / `min-w-*` / `max-w-*` / `w-full`, in any state/breakpoint variant), children that self-wrap in `Expanded`/`Flexible` (`grow`, `flex-grow`, `flex-auto`, `flex-initial`, `shrink`, `flex-shrink`, `flex-N`), 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 turn this off and let children size to content. Rows are never auto-stretched on the cross axis. When the column itself sits in an unbounded-width context (a bare `Row` slot, `UnconstrainedBox`, horizontal scroll), the stretch safely falls back to content-sized children instead of forcing an infinite width.
## Align Content
diff --git a/example/lib/pages/layout/flex_grow.dart b/example/lib/pages/layout/flex_grow.dart
index e7af45a..3a16b87 100644
--- a/example/lib/pages/layout/flex_grow.dart
+++ b/example/lib/pages/layout/flex_grow.dart
@@ -140,6 +140,12 @@ class FlexGrowExamplePage extends StatelessWidget {
],
),
),
+ ExampleSection(
+ title: 'Clickable Row Stretch (WAnchor / WButton)',
+ description:
+ 'WAnchor and WButton column children also stretch to the column width by default. A nav sidebar built from WAnchor rows fills its container without w-full or items-stretch.',
+ child: const _ClickableStretchDemo(),
+ ),
ExampleSection(
title: 'Quick Reference',
description: 'Eight tokens cover the bulk of flex sizing scenarios.',
@@ -290,6 +296,121 @@ class _BasisRow extends StatelessWidget {
}
}
+class _ClickableStretchDemo extends StatelessWidget {
+ const _ClickableStretchDemo();
+
+ @override
+ Widget build(BuildContext context) {
+ return WDiv(
+ className: '''
+ flex flex-col gap-4 p-4 rounded-xl
+ bg-white dark:bg-slate-800
+ border border-slate-200 dark:border-slate-700
+ ''',
+ children: [
+ WText(
+ 'Sidebar nav: WAnchor rows fill column width automatically',
+ className: 'text-xs font-mono text-slate-500 dark:text-slate-400',
+ ),
+ WDiv(
+ className: '''
+ flex flex-col
+ rounded-lg overflow-hidden
+ border border-slate-200 dark:border-slate-700
+ ''',
+ children: [
+ _NavRow(
+ icon: '▶',
+ label: 'Dashboard',
+ color: 'bg-orange-500 dark:bg-orange-600',
+ onTap: () {},
+ ),
+ _NavRow(
+ icon: '◉',
+ label: 'Projects',
+ color: 'bg-orange-400 dark:bg-orange-500',
+ onTap: () {},
+ ),
+ _NavRow(
+ icon: '◈',
+ label: 'Settings',
+ color: 'bg-orange-300 dark:bg-orange-400',
+ onTap: () {},
+ ),
+ ],
+ ),
+ WText(
+ 'WButton rows also stretch, no w-full needed',
+ className: 'text-xs font-mono text-slate-500 dark:text-slate-400',
+ ),
+ WDiv(
+ className: 'flex flex-col gap-2',
+ children: [
+ WButton(
+ className: '''
+ px-4 py-3 rounded-lg
+ bg-slate-100 dark:bg-slate-700
+ text-slate-800 dark:text-slate-100
+ ''',
+ onTap: () {},
+ child: WText(
+ 'Save draft',
+ className: 'font-semibold text-sm',
+ ),
+ ),
+ WButton(
+ className: '''
+ px-4 py-3 rounded-lg
+ bg-orange-500 dark:bg-orange-600
+ ''',
+ onTap: () {},
+ child: WText(
+ 'Publish',
+ className: 'font-semibold text-sm text-white dark:text-white',
+ ),
+ ),
+ ],
+ ),
+ ],
+ );
+ }
+}
+
+class _NavRow extends StatelessWidget {
+ final String icon;
+ final String label;
+ final String color;
+ final VoidCallback onTap;
+
+ const _NavRow({
+ required this.icon,
+ required this.label,
+ required this.color,
+ required this.onTap,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return WAnchor(
+ onTap: onTap,
+ child: WDiv(
+ className: '''
+ flex flex-row items-center gap-3
+ px-4 py-3
+ $color
+ ''',
+ children: [
+ WText(icon, className: 'text-white dark:text-white text-sm'),
+ WText(
+ label,
+ className: 'text-white dark:text-white font-semibold text-sm',
+ ),
+ ],
+ ),
+ );
+ }
+}
+
class _StretchDemo extends StatelessWidget {
final String label;
final String containerClass;
@@ -323,7 +444,7 @@ class _StretchDemo extends StatelessWidget {
''',
child: WText(
'Nav bar',
- className: 'text-white font-semibold text-sm',
+ className: 'text-white dark:text-white font-semibold text-sm',
),
),
WDiv(
@@ -333,7 +454,7 @@ class _StretchDemo extends StatelessWidget {
''',
child: WText(
'Content card',
- className: 'text-white text-sm',
+ className: 'text-white dark:text-white text-sm',
),
),
],
diff --git a/lib/src/widgets/w_div.dart b/lib/src/widgets/w_div.dart
index 5ba860d..c00494c 100644
--- a/lib/src/widgets/w_div.dart
+++ b/lib/src/widgets/w_div.dart
@@ -7,6 +7,7 @@ import 'wind_animation_wrapper.dart';
import '../state/wind_anchor_state_provider.dart';
import '../state/wind_flex_overflow_scope.dart';
import 'w_anchor.dart';
+import 'w_button.dart';
import 'w_text.dart';
/// **The Fundamental Building Block of Wind**
@@ -837,12 +838,49 @@ class WDiv extends StatelessWidget {
/// effect, while breaking trees that locate sibling `SizedBox`es positionally
/// (e.g. a `WSpacer` next to bare `WText`). Container children (`WDiv`) are
/// the meaningful stretch targets.
+ ///
+ /// Interaction wrappers count too. A clickable row is authored as
+ /// `WAnchor(onTap: ...) > WDiv(...)` (WDiv carries no `onTap`), and a
+ /// `WButton` is the canonical button surface; CSS would stretch both to the
+ /// column width. So:
+ /// - `WButton` is eligible, gated by its own `className` exactly like a `WDiv`
+ /// (an explicit width / self-flex / `absolute` opts it out).
+ /// - `WAnchor` delegates its eligibility to its effective child: when the
+ /// child is a `WDiv`, the inner WDiv's `className` decides (a `WAnchor`
+ /// carries no `className`), so a `WAnchor > WDiv(w-32)` keeps 128px and a
+ /// `WAnchor > WDiv` carrying a self-flex token is excluded for the same
+ /// reason a direct self-flexing `WDiv` is; when the child is anything else
+ /// (a `WText`, a raw widget) the wrapper is stretched by policy: the
+ /// `WAnchor` tap surface fills the column, and any explicit width on the
+ /// wrapped widget still constrains its own content.
+ ///
+ /// The inner-WDiv self-flex check mirrors the direct-`WDiv` rule: stretching a
+ /// self-flexing `WDiv` (a `SizedBox(width: infinity)` around a `WDiv` that
+ /// self-wraps in `Expanded`/`Flexible`) asserts ParentDataWidget, so
+ /// `_selfWrapsInFlex` excludes it. A `WAnchor > WDiv(grow)` is itself an
+ /// unsupported configuration: the inner `WDiv`'s `Expanded` asserts because
+ /// its parent is the `WAnchor`'s non-`Flex` wrappers, not a `Flex`. Excluding
+ /// it from stretch does not rescue that case; it only keeps the eligibility
+ /// rule consistent with a direct `WDiv`.
static bool _shouldStretchColumnChild(Widget child) {
final String? className;
if (child is WDiv) {
className = child.className;
+ } else if (child is WButton) {
+ className = child.className;
+ } else if (child is WAnchor) {
+ // A WAnchor has no className of its own; delegate to its effective child.
+ final Widget inner = child.child;
+ if (inner is WDiv) {
+ className = inner.className;
+ } else {
+ // WText / raw widget: stretch the WAnchor surface by policy so the tap
+ // area fills the column; any explicit width on the wrapped widget still
+ // constrains its own content.
+ return true;
+ }
} else {
- // Non-WDiv children (raw Flutter widgets, WText leaves) are untouched.
+ // Non-Wind children (raw Flutter widgets, WText leaves) are untouched.
return false;
}
diff --git a/skills/wind-ui/references/layouts.md b/skills/wind-ui/references/layouts.md
index 9983954..55b6d24 100644
--- a/skills/wind-ui/references/layouts.md
+++ b/skills/wind-ui/references/layouts.md
@@ -28,7 +28,7 @@ Two consequences drive almost every Wind layout footgun:
2. **Scrollables remove the max on their axis.** A `SingleChildScrollView` (Wind's `overflow-y-auto`) passes `maxHeight: double.infinity` to its child. A child that asserts on finite height (`Column` with `Expanded` children) throws "Vertical viewport was given unbounded height".
-3. **`flex flex-col` stretches `WDiv` children to the column width by default.** With NO explicit `items-*` token, each direct `WDiv` child that does not control its own width is wrapped in `SizedBox(width: double.infinity)`, mirroring CSS `align-items: stretch`. 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, and non-`WDiv` children (raw Flutter widgets, `WText`). `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.
+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.
Memorize these and the rest follows.
diff --git a/skills/wind-ui/references/tailwind-divergence.md b/skills/wind-ui/references/tailwind-divergence.md
index 5a278f5..88b7efd 100644
--- a/skills/wind-ui/references/tailwind-divergence.md
+++ b/skills/wind-ui/references/tailwind-divergence.md
@@ -340,4 +340,6 @@ These canonical Tailwind classes are silently ignored by wind (unknown tokens ar
Note: `WText` is self-contained regarding its baseline rendering. When no Material/Scaffold ancestor supplies a `DefaultTextStyle` color, `WText` applies a brightness-aware fallback (`Colors.white` on dark platforms, `Colors.black` on light, read from `MediaQuery.platformBrightness`) instead of Flutter's debug yellow-underline; it also injects a `Directionality(ltr)` wrapper when no `Directionality` is inherited. Explicit `text-*` className, `foregroundColor`, and `textStyle` props override the fallback and are unaffected.
+Note: Wind's smart column cross-axis stretch now covers clickable container rows (`WAnchor` and `WButton`) in addition to plain `WDiv` children, narrowing the divergence from CSS `align-items: stretch` (where element type is irrelevant). A `WAnchor`-wrapped nav row in a `flex flex-col` fills the column width by default without an explicit `items-stretch` or `w-full`.
+
State-shadowing edge: a disabled outer `WAnchor` does NOT suppress a `hover:` class that a nested `WDiv` carries on itself. `WDiv` auto-wraps in its own non-disabled `WAnchor` whose state provider shadows the ancestor's `isDisabled`, so the inner `hover:` still fires. This is an edge case (a disabled control wrapping a separately-hover-styled child); style the disabled state on the same element that owns the `hover:` class, or drive `disabled:` on the inner `WDiv` directly.
diff --git a/test/widgets/w_div/flex_stretch_test.dart b/test/widgets/w_div/flex_stretch_test.dart
index d6eb295..337b887 100644
--- a/test/widgets/w_div/flex_stretch_test.dart
+++ b/test/widgets/w_div/flex_stretch_test.dart
@@ -257,6 +257,218 @@ void main() {
});
});
+ group('smart column stretch (interaction wrappers)', () {
+ testWidgets('WAnchor wrapping a no-width WDiv stretches to parent width',
+ (tester) async {
+ await tester.pumpWidget(
+ wrapWithTheme(
+ SizedBox(
+ width: 300,
+ child: WDiv(
+ className: 'flex flex-col',
+ children: [
+ WAnchor(
+ onTap: () {},
+ child: const WDiv(
+ className: 'bg-red-500',
+ child: SizedBox(height: 10),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+
+ expect(tester.takeException(), isNull);
+ expect(tester.getSize(find.byType(WDiv).at(1)).width, 300);
+ });
+
+ testWidgets('WAnchor wrapping a WText stretches to parent width',
+ (tester) async {
+ await tester.pumpWidget(
+ wrapWithTheme(
+ SizedBox(
+ width: 300,
+ child: WDiv(
+ className: 'flex flex-col',
+ children: [
+ WAnchor(
+ onTap: () {},
+ child: const WText('x'),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+
+ expect(tester.takeException(), isNull);
+ expect(tester.getSize(find.byType(WAnchor)).width, 300);
+ });
+
+ testWidgets('WButton stretches to parent width', (tester) async {
+ await tester.pumpWidget(
+ wrapWithTheme(
+ SizedBox(
+ width: 300,
+ child: WDiv(
+ className: 'flex flex-col',
+ children: [
+ WButton(
+ onTap: () {},
+ child: const WText('x'),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+
+ expect(tester.takeException(), isNull);
+ expect(tester.getSize(find.byType(WButton)).width, 300);
+ });
+
+ testWidgets('WAnchor wrapping a w-32 WDiv keeps its width (no stretch)',
+ (tester) async {
+ await tester.pumpWidget(
+ wrapWithTheme(
+ SizedBox(
+ width: 300,
+ child: WDiv(
+ className: 'flex flex-col',
+ children: [
+ WAnchor(
+ onTap: () {},
+ child: const WDiv(
+ className: 'w-32 bg-red-500',
+ child: SizedBox(height: 10),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+
+ expect(tester.takeException(), isNull);
+ // w-32 = 32 * 4px = 128px; the inner explicit width disables stretch.
+ expect(tester.getSize(find.byType(WDiv).at(1)).width, 128);
+ });
+
+ testWidgets(
+ 'stretch-eligible WAnchor in an unbounded-width column does not crash '
+ '(degrades to content size)', (tester) async {
+ // A flex-col in a bare Row main-axis slot has unbounded width. A
+ // stretch-eligible WAnchor would otherwise be wrapped in
+ // SizedBox(width: infinity), which throws under an unbounded constraint.
+ // The bounded-width LayoutBuilder guard must skip the wrap and let the
+ // WAnchor degrade to its content size instead of crashing.
+ await tester.pumpWidget(
+ wrapWithTheme(
+ Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ WDiv(
+ className: 'flex flex-col',
+ children: [
+ WAnchor(
+ onTap: () {},
+ child: const WText('content'),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ );
+
+ expect(tester.takeException(), isNull,
+ reason: 'WAnchor smart stretch must not throw on unbounded width');
+ });
+
+ testWidgets('WButton with explicit width keeps its width (no stretch)',
+ (tester) async {
+ await tester.pumpWidget(
+ wrapWithTheme(
+ SizedBox(
+ width: 300,
+ child: WDiv(
+ className: 'flex flex-col',
+ children: [
+ WButton(
+ onTap: () {},
+ className: 'w-32',
+ child: const WText('x'),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+
+ expect(tester.takeException(), isNull);
+ // w-32 = 128px; the button's own explicit width disables stretch.
+ expect(tester.getSize(find.byType(WButton)).width, 128);
+ });
+
+ testWidgets('self-flexing WButton (grow) is not stretch-wrapped',
+ (tester) async {
+ // A WButton whose className carries a self-flex token (grow) is excluded
+ // from the stretch pass by _selfWrapsInFlex, mirroring the direct-WDiv
+ // rule. WButton itself does not wrap in Expanded; the exclusion keeps the
+ // eligibility rule consistent.
+ await tester.pumpWidget(
+ wrapWithTheme(
+ SizedBox(
+ width: 300,
+ height: 200,
+ child: WDiv(
+ className: 'flex flex-col',
+ children: [
+ WButton(
+ onTap: () {},
+ className: 'grow',
+ child: const WText('x'),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+
+ expect(tester.takeException(), isNull,
+ reason: 'self-flexing WButton must not be stretch-wrapped');
+ });
+
+ testWidgets('bare WText direct column child is still NOT stretched',
+ (tester) async {
+ await tester.pumpWidget(
+ wrapWithTheme(
+ const SizedBox(
+ width: 300,
+ child: WDiv(
+ className: 'flex flex-col',
+ children: [
+ WText('x'),
+ ],
+ ),
+ ),
+ ),
+ );
+
+ expect(tester.takeException(), isNull);
+ // The bare WText leaf must not be wrapped in a stretch SizedBox.
+ final column = tester.widget(find.byType(Column));
+ final stretched = column.children
+ .whereType()
+ .where((box) => box.width == double.infinity)
+ .length;
+ expect(stretched, 0,
+ reason: 'bare WText leaf stays unstretched (existing exclusion)');
+ });
+ });
+
group('smart column stretch — edge cases', () {
testWidgets('bounded-width column in a vertical scroll still stretches',
(tester) async {