Wind UI 1.5
Utility-first Flutter styling. Every visual decision lives in a className: String? parsed at build time into an immutable WindStyle and composed into a native Flutter widget tree. Tailwind syntax (flex, p-4, dark:bg-gray-800, hover:shadow-lg), Flutter physics.
This skill assumes the host app already depends on fluttersdk_wind and has WindTheme wrapping MaterialApp. If a fresh project needs setup (rare; the skill normally triggers on an already-installed project), see Quick install at the bottom.
0. Before writing UI in this project
Three quick checks. Each pays off across the whole session.
- Confirm Wind is installed. Look at
pubspec.yamlforfluttersdk_wind:. If absent, jump to §13 first. - Read the project's
WindThemeDatasetup. Usually inlib/main.dartorlib/config/wind.dart. Note any custom color families (primary,accent,incident, etc.): those become available asbg-primary-500,text-incident-700, etc. without registration. Skip this and the agent risks writing tokens that silently no-op, or missing the brand palette entirely. - Scan one existing view in
lib/for the project's className idioms. Triple-quoted style? Single-line preferred? Custom states (pressed:,expanded:)? Match the surrounding code, don't invent a new dialect.
After these, the agent has the project's color landscape, breakpoint set, and className voice loaded.
The parser cache is near-100% hit-rate in production. Do not worry about className parse overhead; the same className parses exactly once for its (breakpoint, brightness, platform, states) tuple. Prefer expressive className over inline BoxDecoration / EdgeInsets for "performance" reasons; the cache handles it.
1. Core Laws
These hold for every line of Wind code. Apply each as a hard constraint, not a suggestion.
className is the styling surface. Inline Dart props (
backgroundColoronWDiv,foregroundColoronWText) exist only as runtime-dynamic escape hatches for values the cache key cannot represent. Default to className. Never reach forBoxDecoration,EdgeInsets,TextStylewhen a token covers it.Every
bg-/text-/border-/ring-/shadow-/fill-carries adark:peer in the same className. Missing pair is a bug, not a style choice. Pairbg-white dark:bg-gray-800on the same line, not at the top and bottom of a multi-line className. Wind's dark-mode contract: the agent never opts in; every color opts in by default.Conditional styling routes through
states: Set<String>?plus prefixed classes. Never interpolate Dart expressions into className.'bg-${isOn ? "blue" : "gray"}-500'breaks the parser cache and is a bug. The right shape:WDiv( className: ''' rounded-lg p-4 border-2 border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 selected:border-blue-500 selected:bg-blue-50 dark:selected:border-blue-400 dark:selected:bg-blue-950 ''', states: isSelected ? const {'selected'} : const {}, child: ..., );Inside a Row (
flex flex-row), preferflex-1for a fill-the-row child. A barew-fullon a direct Row child is now treated asflex-1(the row wraps it inExpanded), so it fills the available width instead of assertingRenderBox was not laid out;flex-1stays the idiomatic, explicit choice and is what to reach for. (A prefixedmd:w-fullis NOT auto-expanded; usemd:flex-1.) Inside a Column (flex flex-col), scrollable children useflex-1 overflow-y-autoplus the constructor propscrollPrimary: truefor iOS tap-to-top.h-fullinside a scrollable parent still triggers "Vertical viewport was given unbounded height".childXORchildrenon every W-widget that accepts both. Passing both fails an assertion at construction. Passing neither renders an emptySizedBox.Unknown tokens are dropped; the debug hint fires only for tokens no parser recognizes. Two cases, and they behave differently. A token whose prefix matches NO parser (
ps-4logical-inline,-m-4negative margin, a mistyped family likewibble-4) is dropped and, inkDebugMode, prints a one-timedebugPrintnaming it (deduped per unique token per session; release builds stay silent). A token whose family IS recognized but whose value is unsupported (text-7xl, past wind'stext-6xlcap;flex-cow, which still matchesflex-*) is claimed by that parser and drops SILENTLY, with no hint. So the hint catches unknown-family typos, not bad-value ones; spell-check values by hand or loadreferences/tokens.mdto verify the family.Last class wins within a parser family.
p-4 p-8resolves top-8.bg-red-500 bg-blue-500resolves tobg-blue-500. Conflicts inside the same property are stable but silent; conflicts across properties (text-red-500color +text-centeralignment) coexist because they target different fields.WindThemelives BELOWMaterialAppin the runtime tree. The builder pattern inverts apparent order:WindTheme(data: ..., builder: (ctx, controller) => MaterialApp(...)). Consequence:OverlayEntry.buildercontexts cannot reachWindThemevia ancestor walk. Capture the State'scontextbefore showing an overlay, then pass it toWindParser.parsefrom inside the overlay builder.WPopover/WSelectalready handle this internally.Wind composes with Flutter, not against it.
Scaffold,AppBar,Dialog,BottomSheet,Drawer,SnackBar,Navigator,Hero,FutureBuilder,StreamBuilder,ValueListenableBuilderremain canonical.ListView/GridView.builder/CustomScrollVieware the right choice for virtualised lists;WDivwithgrid-cols-Nproduces a staticWrap(or, withitems-stretch, equal-height rows), not a virtualised grid. See Wind ≠ Flutter rules of thumb.active:prefix is reserved but not wired.WAnchortracks hover and focus only; there is no onTapDown/onTapUp tracking. Don't rely onactive:bg-blue-700for press feedback. Use a transient state in the consumer's controller andstates: {'pressed'}if you genuinely need press feedback today.
2. The 27 public widgets (+ WindRecipe) at a glance
fluttersdk_wind v1 ships 27 public widgets plus the WindRecipe / WindSlotRecipe variant-composition primitives, all imported from the single barrel package:fluttersdk_wind/fluttersdk_wind.dart. No sub-barrels exist; do not write import 'package:fluttersdk_wind/widgets.dart'.
The headline 25 (table below) are the ones an agent reaches for daily. Two more cover narrow surfaces and live outside the table: WKeyboardActions (iOS keyboard toolbar overlay for Done / Next actions on a focused TextField) and WindAnimationWrapper (the internal stateful wrapper that drives looping animate-* tokens; consumers normally do not instantiate it directly).
| Widget | Category | Required positional | One-line purpose |
|---|---|---|---|
WDiv |
Layout / container | none | Universal container; auto-wraps in WAnchor when className contains hover: / focus: / active:. child XOR children. Inline color prop: backgroundColor. |
WSpacer |
Layout | none | Lightweight SizedBox that reads only w-N / h-N. Skips every other token. |
WBreakpoint |
Structural | none | Per-breakpoint WidgetBuilder map (base, sm, md, lg, xl, xxl, plus theme-defined custom keys). Escape hatch when className prefixes are not enough. |
WText |
Display | data: String |
Typography; supports selectable prop. Inline color prop: foregroundColor. No child / children. |
WIcon |
Display | icon: IconData |
Material icons; use Icons.*_outlined variants by convention. Reads text-* for size AND color (overloaded). Inherits from DefaultTextStyle when className is absent. Inline color prop: foregroundColor. |
WImage |
Display | none (requires src or image) |
Network (URL) or asset (prefix asset://path) or ImageProvider. object-cover default. |
WSvg / WSvg.string |
Display | src / svg |
Vector graphics. fill-* / stroke-* for color. preserve-colors token disables tint for multi-color SVGs (QR codes, logos). |
WAnchor |
Interactive | child: Widget |
Low-level gesture + focus + hover propagator. Emits Semantics(button: true) only when it carries a gesture, or when semanticLabel is set. |
WButton |
Interactive | child: Widget |
Wraps WAnchor + WDiv + built-in spinner. isLoading: true injects loading: state. disabled: true injects disabled: state and blocks taps. |
WPopover |
Overlay | none (requires builders) | OverlayPortal-based; triggerBuilder(ctx, isOpen, isHovering) + contentBuilder(ctx, close) + optional PopoverController. Auto-flips alignment when bottom space is insufficient. |
WInput |
Form (raw) | none | Material-free text input (EditableText core); works under Material, Cupertino, custom, or bare WidgetsApp (no Material ancestor required). value + onChanged for controlled binding, or controller for imperative needs; passing both throws AssertionError in debug. InputType enum: text / password / email / number / multiline (number restricts to a signed decimal on every platform incl. web; pass inputFormatters to override). readOnly: true activates a readonly: state like enabled: false activates disabled:. Native text selection: mouse-drag selects a substring, double-click/double-tap selects a word, tapping the box moves the cursor; selection handles are Cupertino-style on all platforms (keeps WInput cupertino-only, no material.dart import). An Overlay ancestor is required for interactive selection; without one, typing and focus still work but all interactive selection (drag-select, double-tap, long-press, handles, and toolbar) is suppressed. Emits exactly one typeable textbox semantics node carrying semanticLabel ?? placeholder; password reports obscured. |
WCheckbox |
Form (raw) | none | Boolean checkbox; auto-injects checked: state when value: true. Default className includes checked:bg-primary (primary is a seeded default token aliased to blue; override it in WindThemeData.colors to rebrand). A null onChanged renders it display-only, exactly like disabled: true: no tap action, reported as not enabled, disabled: styles active. WRadio and WSwitch read a null callback the same way. |
WSelect<T> |
Form (raw) | options: List<SelectOption<T>> |
Single OR multi-select dropdown with overlay. Supports searchable, async search, async create (tagging), pagination via onLoadMore + hasMore. Auto-flips upward when bottom space < maxMenuHeight. |
WDatePicker |
Form (raw) | none | single date, DateRange, OR dateTime mode; popover-based calendar; min/max constraints. dateTime keeps the time of day (stepped time row, minuteStep / timeLabel / doneLabel) where the other two strike every value to midnight. |
WFormInput |
Form (FormField) | none | extends FormField<String>; auto-injects error: when validation fails; renders label / hint / error around WInput. |
WFormSelect<T> |
Form (FormField) | options: List<SelectOption<T>> |
extends FormField<T>; single-select with validation. |
WFormMultiSelect<T> |
Form (FormField) | options: List<SelectOption<T>> |
extends FormField<List<T>>; multi-select with validation; validator inspects the full list. |
WFormCheckbox |
Form (FormField) | none | extends FormField<bool>; validation hook + label + error display. |
WFormDatePicker |
Form (FormField) | none | extends FormField<DateTime>. Forwards mode / minuteStep / timeLabel / doneLabel, so dateTime gives the validator a full instant. Range mode stores range.start only in FormFieldState; validators only see the start date. |
WDynamic |
Structural / SSR | json: Map |
Renders a JSON node tree into Wind widgets. 13 Wind types + 16 Flutter core types allowed by default; builders: adds custom types; denyWidgets: blocks. Max recursion depth default 50. |
WBadge |
Display | label: String |
Inline status/label pill. Composes a rounded-full WDiv around WText(text-xs). All tone via className (bg-*, text-*, dark: pairs). No positional child; label is the only positional param. |
WCard |
Layout / container | child: Widget |
Surface container. header:, child: (required), footer: slots; delegates to WDiv(flex-col). No colors baked in; all tone via className. |
WSwitch |
Form (raw) | none | Controlled toggle. value + onChanged. className styles the track; thumbClassName styles the indicator dot. checked: state activates when value: true. The thumb is a flex child of the track, so it slides via justify-start -> checked:justify-end on the track className (Wind has no transform parser; translate-x-* is a no-op). disabled: when disabled: true. |
WRadio<T> |
Form (raw) | none | Controlled radio. value, groupValue, onChanged. selected: activates when value == groupValue. Outer ring: className. Inner dot: indicatorClassName (defaults to blue filled circle). Group exclusivity is the caller's responsibility. |
WTabs |
Form (raw) / Layout | none | Controlled tabs. tabs: List<String>, selectedIndex, panelBuilder. selected: activates on the active tab. Slot classNames: listClassName, tabClassName, selectedTabClassName, panelClassName. fullWidthList (default true) prepends w-full so a border-b underline spans the container; set false for content-width / pill tabs. |
Full constructor surface, every named parameter, every default: ${CLAUDE_SKILL_DIR}/references/widgets.md.
WindRecipe / WindSlotRecipe (variant-composition primitives)
WindRecipe and WindSlotRecipe are callable objects (not widgets) that compose className strings from variant axes. Use them to centralise multi-variant component styling instead of scattering conditional className strings across the call sites.
final button = WindRecipe(
base: 'flex flex-row items-center rounded-lg font-medium',
variants: {
'intent': {'primary': 'bg-blue-600 dark:bg-blue-500 text-white', 'ghost': 'bg-transparent text-blue-600 dark:text-blue-400'},
'size': {'sm': 'px-3 py-1.5 text-sm', 'md': 'px-4 py-2 text-base', 'lg': 'px-6 py-3 text-lg'},
},
compoundVariants: [
WindCompoundVariant(conditions: {'intent': 'primary', 'size': 'lg'}, className: 'shadow-lg'),
],
defaultVariants: {'intent': 'primary', 'size': 'md'},
);
button() // uses defaults
button(variants: {'intent': 'ghost'}) // override one axis
button(variants: {'size': null}) // null clears the default (no size classes)
button(className: 'w-full') // caller suffix, appended last
Resolution order (strict, never sorted): base ++ variant-classes(definition order) ++ matched-compound(array order) ++ caller.
Same-granularity contract: a variant must override a base token at the same granularity. Mixing p-4 (base) and px-2 (variant) is silent: the parser's per-family last-wins keeps the shorthand. Override px-* with px-* or p-* with p-*.
WindSlotRecipe returns Map<String, String> (slot -> className). Use the slot keys directly on widget className: props.
Core Law addition for recipes: pass enum variant values as .name (ButtonIntent.ghost.name); recipe axes are plain strings.
Caller-append contract (no twMerge): the recipe only appends the caller's className last; it never dedupes or resolves conflicts itself. WindRecipe(base: 'w-1/2')(className: 'w-full') emits 'w-1/2 w-full', both tokens intact. The conflict resolves one layer down: WindParser groups same-family classes and each parser applies last-class-wins, so the appended w-full wins at parse time. Wind deliberately has no Dart twMerge/cn port; see doc/styling/wind-recipe.md "The Caller-Append Contract (No twMerge)". A component that treats an incoming className as a full replacement instead of appending it is a consumer bug, not a wind defect.
3. The state system (three layers)
| Layer | Set by | Examples |
|---|---|---|
| Automatic | WAnchor from pointer / keyboard events |
hover: (MouseRegion onEnter/onExit), focus: (FocusNode listener) |
| Framework-managed | The widget itself, from its own props | loading: (WButton.isLoading), disabled: (any widget's disabled / enabled prop), checked: (WCheckbox.value, WSwitch.value), selected: (WRadio when value==groupValue, WTabs active tab), error: (WForm* when FormFieldState.hasError) |
| Consumer-passed | states: Set<String>? on the widget |
selected: (card toggles), highlighted:, new:, any custom string. No registration required. |
WDiv and WButton auto-wrap themselves in WAnchor whenever className contains the literal substrings hover:, focus:, or active:. Other widgets do not. If you need hover detection on a WText, wrap it in WDiv or WAnchor explicitly.
All active states merge into a single Set<String> inside WindContext.activeStates and contribute to the parser cache key. Manual states: {'hover'} and a real pointer hover produce the same cache entry by design.
Combining prefixes:
md:hover:bg-blue-500 // breakpoint AND hover
dark:md:hover:bg-blue-400 // dark AND breakpoint AND hover
ios:focus:ring-blue-500 // iOS only AND focused
md:dark:selected:border-2 // breakpoint AND dark AND selected
Prefix order does not matter at the parser level; all stacked prefixes must match for the class to activate.
4. The token landscape (high-frequency subset)
Inline this catalog as your default reach-for set. For the full per-parser regex catalog (every flag, every arbitrary-value pattern): ${CLAUDE_SKILL_DIR}/references/tokens.md.
Layout: flex flex-row flex-col flex-row-reverse flex-col-reverse wrap grid grid-cols-N block hidden. justify-start -center -end -between -around -evenly. items-start -center -end -baseline -stretch. axis-min axis-max (Wind-only, sets MainAxisSize). On a grid, items-stretch opts into equal-height rows (cells match the tallest per row); the default grid sizes each cell to its own content.
Flex child: flex-1 flex-auto flex-none flex-N (numeric). shrink-0 grow. self-start / -end / -center / -stretch / -auto (align-self shorthand; align-self-* long form also works). order-0 through order-12, order-first / order-last / order-none, arbitrary order-[-5].
Spacing: p-N px-N py-N pt-N pr-N pb-N pl-N (no ps-/pe-). m-N and axes (no negative margin, no ms-/me-, mx-auto for horizontal centering). gap-N gap-x-N gap-y-N space-x-N space-y-N. Arbitrary p-[18px], gap-[3.5] (no % for spacing). Default unit: 4 px per step. p-4 = 16 px.
Sizing: w-N h-N (theme scale). w-1/2 w-1/3 w-2/3 w-1/4 w-3/4 w-full w-screen. h-full h-screen. size-N sets BOTH width and height (size-2, size-full, size-1/2, size-[20px]); works on a childless WDiv (status dot). Arbitrary w-[300px] h-[50%]. min-w-0 min-w-full min-h-screen. max-w-xs through max-w-7xl, max-w-prose, max-w-full. No w-auto / h-auto (silently skipped).
Position: relative absolute. top-N right-N bottom-N left-N inset-N inset-x-N inset-y-N, negative -top-N -inset-N, arbitrary top-[24px] (no % for offsets). fixed / sticky are recognised by the parser but produce no visual effect.
Colors (every line needs a dark: peer): bg-{family}-{shade} bg-[#hex] bg-transparent bg-white bg-black. Opacity modifier /N (0-100): bg-red-500/50. Same shape for text-* border-* ring-* shadow-* fill-* stroke-*. Bare shade defaults to 500: bg-red = bg-red-500. Gradients: bg-gradient-to-{t|tr|r|br|b|bl|l|tl} + from-{c}-{shade} via-{c}-{shade} to-{c}-{shade}.
Borders: border border-N border-t border-r border-b border-l border-x border-y. border-solid border-none (only these two; border-dashed / border-dotted are recognised but not wired). rounded rounded-{sm|md|lg|xl|2xl|3xl|full|none}, directional rounded-t-lg rounded-tl-xl, arbitrary rounded-[8px].
Typography (order of resolution inside text-*): color → align → size → weight → style. text-xs text-sm text-base text-lg text-xl text-2xl through text-6xl (60 px). text-7xl / text-8xl / text-9xl are no-ops; do not write them. font-thin through font-black. text-left text-center text-right text-justify text-start text-end (RTL-aware). truncate (= text-ellipsis + maxLines: 1 + softWrap: false). line-clamp-N. whitespace-nowrap / text-nowrap. uppercase lowercase capitalize normal-case (capitalize raises the first letter of every word and leaves the rest as typed, so the HTTP client is The HTTP Client and well-known is Well-Known, while a digit, an underscore, an apostrophe or a combining mark continues the word, so 3rd party, l'orange and decomposed NFD text keep theirs; casing follows the ambient locale, so tr/az get the dotted/dotless i right, and it falls back to locale-independent casing with no Localizations ancestor). italic / not-italic. underline line-through no-underline, plus decoration-{color}/{style}/{thickness}. leading-tight leading-snug leading-normal leading-relaxed leading-loose or arbitrary leading-[24px]. tracking-tighter through tracking-widest. Font size + line height combined: text-xl/8.
Effects: opacity-N (5-step scale, plus arbitrary opacity-[0.5]). shadow-sm shadow shadow-md shadow-lg shadow-xl shadow-2xl shadow-inner shadow-none. Colored shadow shadow-blue-500/20. ring-N ring-{color} ring-offset-N ring-inset. aspect-square aspect-video aspect-[4/3]. z-0 z-10 through z-50, arbitrary z-[100], z-auto.
Overflow: overflow-hidden overflow-visible overflow-scroll overflow-auto, axis-specific overflow-x-auto overflow-y-auto. Scrolling requires the constructor prop scrollPrimary: true for iOS tap-to-top (there is no className for it).
Cursor (web/desktop; WDiv adds a MouseRegion, inert on touch): cursor-pointer cursor-default cursor-text cursor-wait cursor-progress cursor-help cursor-not-allowed cursor-none cursor-move cursor-grab cursor-grabbing cursor-zoom-in cursor-zoom-out cursor-col-resize cursor-row-resize cursor-{n,e,s,w,ne,nw,se,sw,ew,ns,nesw,nwse}-resize plus cursor-context-menu cursor-cell cursor-crosshair cursor-alias cursor-copy cursor-no-drop cursor-all-scroll. Last token wins; unknown names no-op.
Transitions / animations: duration-{75|100|150|200|300|500|700|1000} plus arbitrary duration-[500ms]. ease-linear ease-in ease-out ease-in-out. animate-spin animate-pulse animate-ping animate-bounce animate-none. The bare transition / transition-all / transition-colors tokens are recognised in docs but NOT wired in the parser; pair duration-* + ease-* to enable transitions on opacity and color changes.
Prefixes: Responsive sm: md: lg: xl: 2xl: (default 640 / 768 / 1024 / 1280 / 1536 px, customizable via WindThemeData.screens). Dark dark:. Platform ios: android: macos: web: mobile: windows: linux:. State (Core Laws §3). Stackable freely.
5. Wind ≠ Tailwind (the cheat sheet a web developer needs first)
A developer fluent in Tailwind v3 or v4 will default to assumptions Wind partially honours, partially rejects. Full divergence catalog: ${CLAUDE_SKILL_DIR}/references/tailwind-divergence.md.
| Tailwind expectation | Wind reality |
|---|---|
flex-wrap enables wrapping |
Aliased to wrap (Flutter Wrap is a separate widget); prefer wrap directly, flex-wrap prints a one-time debug hint. |
Font sizes go to 9xl |
Stop at 6xl (60 px). 7xl/8xl/9xl silently no-op. |
text-7xl typo fails loudly |
A token no parser recognizes is dropped with a one-time kDebugMode hint; a recognized family with an unsupported value (like text-7xl, claimed by text-*) drops silently. Hand-check values. |
| Spacing in rem | Logical pixels (4 px per unit; p-4 = 16 px). Adjust via WindThemeData.baseSpacingUnit. |
w-full inside a Row works |
A bare w-full row child is treated as flex-1 (wrapped in Expanded) and fills the row; prefer flex-1 for clarity. md:w-full is not auto-expanded. |
h-full inside a scrollable parent works |
Triggers unbounded-height assertion. Use min-h-screen or wrap the parent in a fixed-height container. |
overflow-y-auto enables iOS tap-to-top |
Add the constructor prop scrollPrimary: true as well. There is no className for it. |
dark: is optional |
Every color token needs a dark: peer in the same className (Core Law §2). |
bg-opacity-50 (v3) |
Removed in Wind. Use the v4-style bg-red-500/50. |
!flex (v3 important) / flex! (v4 important) |
Does not exist. Re-order or use states: instead. |
@apply, @layer, @variant, @theme, theme directives |
Do not exist. Wind has no CSS layer. |
@container / @sm: container queries |
Do not exist. Viewport breakpoints only. |
group-* / peer-* sibling state selectors |
Do not exist. Use states: for cross-widget state. |
divide-*, filter, backdrop-blur, 3D transforms |
Do not exist. |
ps-* / pe-* / ms-* / me-* logical inline |
Do not exist. Use pl- / pr- / ml- / mr- (physical). |
Negative margins -m-4 |
Not supported. Restructure layout instead. |
w-auto / h-auto |
Silently skipped. Omit the token (Flutter defaults to intrinsic sizing). |
shadow-sm / shadow / rounded-sm / rounded semantics |
Match Tailwind v3 (Wind has not rolled the v4 rename one step down). |
Wind-only additions Tailwind lacks: ios: android: macos: windows: linux: web: mobile: platform prefixes. axis-min / axis-max for Flutter MainAxisSize. Inline color props (WDiv.backgroundColor, WText.foregroundColor) that bypass the cache key. WBreakpoint for per-breakpoint widget builders. WDynamic for JSON-driven trees. preserve-colors token for WSvg.
6. Flutter constraint reality (the six layout rules CSS does not have)
Wind hides most boilerplate but never changes Flutter's "constraints down, sizes up, parent sets position" model. Six rules cover ~90% of overflow errors. Full recipe catalog + assertion-to-fix mapping: ${CLAUDE_SKILL_DIR}/references/layouts.md.
| 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')) |
Nested flex with truncate needs min-w-0 |
WDiv(className: 'flex-1 flex flex-col', children: [WText(..., className: 'truncate')]) |
WDiv(className: 'flex-1 flex flex-col min-w-0', children: [WText(..., className: 'truncate')]) |
| Icon buttons need ≥48 dp touch target | WButton(child: WIcon(Icons.close_outlined)) (visual size 24 dp) |
WButton(className: 'p-3 rounded-lg', child: WIcon(Icons.close_outlined)) (48 dp tap target) |
Icon-only buttons need semanticLabel |
WButton(child: WIcon(Icons.close_outlined)) (nameless to screen readers / getByRole) |
WButton(semanticLabel: 'Close', child: WIcon(Icons.close_outlined)) |
semanticLabel overrides child text |
WButton(semanticLabel: 'Save', child: WText('Save')) (label set alongside visible text; the child's text is excluded from semantics) |
omit it when the child has readable text; prefer it only for icon-only controls where no text is present |
semanticLabel excludes the word "button" |
semanticLabel: 'Close button' (announced as "Close button button") |
semanticLabel: 'Close' (the role appends "button") |
items-stretch inside a SingleChildScrollView needs an IntrinsicHeight wrapper from native Flutter; Wind has no token for it. Rare; reach for it when row children inside a scroll must match heights.
Intrinsic sizing limitation. Wrapping Wind content in IntrinsicHeight / IntrinsicWidth (or a Row/Column that needs child intrinsic heights for equal-height columns) throws LayoutBuilder does not support returning intrinsic dimensions WHEN that content contains a grid, which composes a Wrap inside a LayoutBuilder to compute its column width. LayoutBuilder cannot answer intrinsic queries (a Flutter constraint, not a Wind bug). h-full and flex basis-* are NO LONGER triggers: both resolve through render objects (WindFullHeightBox, WindMainExtentProvider), and a render object answers intrinsics. Escape hatches for the grid case: explicit h-* / size-* cells, or a Stack + Positioned(top:0,bottom:0); Wind's own items-stretch grid equalizes row heights with real layout, so reach for it INSTEAD of IntrinsicHeight.
7. className formatting
Multi-line triple-quoted when the className covers 3+ concerns. One concern per line. Group dark: peers beside their light variant, not at the bottom.
Wrong (one long line, hard to scan, easy to miss a missing dark: peer):
className: 'rounded-lg p-4 border-2 border-gray-200 bg-white text-gray-900 hover:bg-gray-50',
Right (concerns visible, dark pairs paired inline, hover state grouped):
WDiv(
className: '''
rounded-lg p-4 border-2
border-gray-200 dark:border-gray-700
bg-white dark:bg-gray-800
text-gray-900 dark:text-white
hover:bg-gray-50 dark:hover:bg-gray-700
''',
child: ...,
);
Single line is fine when the className is genuinely short (1-2 concerns, ≤ 60 chars). Don't force triple-quoting on 'text-lg font-bold'.
Never embed Dart expressions in className. Route every dynamic visual through states: plus prefixed classes (Core Law §3), or through backgroundColor / foregroundColor inline props for runtime-dynamic colors.
8. Forms (when to reach for WForm*)
Two widget families, parallel surface, different scope.
| Family | Use when | Validation |
|---|---|---|
Raw WInput / WSelect / WCheckbox / WDatePicker |
Stateless reads, controlled forms managed by an external state library (Riverpod, Bloc, ChangeNotifier), or single fields without a Form | Manual: caller inspects value and renders error UI |
WFormInput / WFormSelect / WFormMultiSelect / WFormCheckbox / WFormDatePicker |
Multi-field forms inside Form + GlobalKey<FormState>. Each widget extends FormField<T> and integrates with FormState.validate() |
Auto: pass validator: (T?) => String?. state.hasError injects the error: state for error:border-red-500 styling. Pass autovalidateMode: AutovalidateMode.onUserInteraction to keep the form quiet until first interaction. |
Idiomatic form:
final _formKey = GlobalKey<FormState>();
Form(
key: _formKey,
child: WDiv(
className: 'flex flex-col gap-4 p-6',
children: [
WFormInput(
label: 'Email',
type: InputType.email,
autovalidateMode: AutovalidateMode.onUserInteraction,
className: '''
rounded-lg p-3 border
border-gray-300 dark:border-gray-600
bg-white dark:bg-gray-800
focus:ring-2 focus:ring-blue-500
error:border-red-500
''',
validator: (value) {
if (value == null || value.isEmpty) return 'Required';
if (!value.contains('@')) return 'Invalid email';
return null;
},
onSaved: (value) => _email = value ?? '',
),
WButton(
className: 'bg-blue-600 dark:bg-blue-500 hover:bg-blue-700 dark:hover:bg-blue-600 text-white px-6 py-3 rounded-lg',
onTap: () {
if (_formKey.currentState!.validate()) {
_formKey.currentState!.save();
_submit();
}
},
child: const Text('Submit'),
),
],
),
);
For server-side / async validation, use FormField.forceErrorText: run the async work after _formKey.currentState!.validate() returns true, then setState(() => _serverError = result.message) and pass forceErrorText: _serverError on the relevant field. Wind's WForm* widgets inherit forceErrorText from FormField.
WFormDatePicker range mode gotcha: the FormFieldState<DateTime> stores only range.start. A validator inspecting "is the range complete" must reach for the controller's internal state separately.
Full Form patterns + validation recipes + async error flow: ${CLAUDE_SKILL_DIR}/references/forms.md.
9. Wind ≠ Flutter rules of thumb
| Need | Reach for |
|---|---|
| App shell, navigation rails, drawer | Scaffold (Material), not WDiv |
| Title bar | AppBar / SliverAppBar (Material) |
| Modal sheet | showModalBottomSheet(...) (Material), content built with Wind |
| Toast / snackbar | ScaffoldMessenger.of(context).showSnackBar(...) (Material) |
| Dialog / confirm | showDialog(...) + AlertDialog (Material), content built with Wind |
| Virtualised list (large or dynamic item count) | ListView.builder / GridView.builder (Flutter), each item built with Wind |
| Tab bar | TabBar / TabBarView (Material) or NavigationBar (Material 3) |
| Cross-route shared animation | Hero (Flutter), wrap the W-widget being shared |
| Multi-property implicit animation | AnimatedContainer (Flutter); Wind's animate-* is for looping animations only (spin / pulse / ping / bounce) |
| Routing | go_router (community-canonical) or Navigator 2.0; Wind has no router |
| Async state stream | FutureBuilder / StreamBuilder / ValueListenableBuilder (Flutter), composed with W-widgets in the builder |
| Static row / column / wrap / stack | WDiv with flex / flex-row / flex-col / wrap / relative+absolute tokens |
| Static grid | WDiv with grid grid-cols-N gap-N (renders as Wrap, not virtualised; add items-stretch for equal-height rows) |
| Form integration with validation | WForm* family inside Flutter's Form + GlobalKey<FormState> |
WDynamic is the JSON-driven alternative when a server, A/B framework, or remote-config service supplies the widget tree at runtime. Whitelisted by default (13 Wind + 16 Flutter core types); extend with builders: / customIcons:; restrict with denyWidgets:. Reach for it only when remote rendering is a hard requirement; for static UI write Dart.
10. Definition of done for a Wind change
Per change (every UI edit), verify all seven:
- Every color token has a
dark:peer in the same className block. Grep your diff forbg-,text-,border-,ring-,fill-,shadow-and confirm adark:peer on each. - Every multi-concern className (3+ token families) is triple-quoted, one concern per line, dark pairs grouped beside their light variant.
- Every interactive surface has
hover:andfocus:states (web/desktop targets) onWButton,WAnchor,WInput,WSelect,WCheckbox,WDatePicker, plusdisabled:styling wherever the widget exposes anenabled/disabledparameter. - Every
childXORchildren: never both. Construction-time assertion. - Every scrollable root
WDivhasscrollPrimary: trueon at least one ancestor in the scroll chain so iOS status-bar-tap scrolls to top. - No Dart string interpolation inside className, no inline
BoxDecoration/TextStyle/EdgeInsetswhen a token covers it, noIcons.*(non-outlined) without a deliberate reason, noWIconwithoutIcons.*_outlined. - Every icon-only
WButton/WAnchorhas asemanticLabel: without it the control is nameless to screen readers andgetByRole('button', { name }). When set, the child subtree is excluded from semantics, sosemanticLabeloverrides any child text rather than concatenating with it. Prefer it for icon-only controls; omit it when the child already carries readable text. Do not put the word "button" in the label; the role appends it.
Run dart analyze on the touched files and visually verify the change in light AND dark mode (toggle via context.windTheme.toggleTheme() if there is no UI for it yet) before reporting done. Missing dark pairs only surface when the app is actually in dark mode.
11. Common patterns (the agent will reach for these first)
| Pattern | Recipe |
|---|---|
| Centered card | WDiv(className: 'mx-auto max-w-md p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm', child: ...) |
| Vertical stack | WDiv(className: 'flex flex-col gap-4', children: [...]) |
| Horizontal stack | WDiv(className: 'flex flex-row items-center gap-3', children: [...]) |
| Responsive grid 1→2→3 | WDiv(className: 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4', children: cards) |
| Sticky header + scrollable body | Outer flex flex-col h-full, header flex-shrink-0, body flex-1 overflow-y-auto + scrollPrimary: true |
| Full-page scroll | WDiv(className: 'w-full h-full overflow-y-auto p-4', scrollPrimary: true, child: ...) |
| Hide on mobile, show md+ | className: 'hidden md:flex' |
| Gradient header banner | bg-gradient-to-br from-indigo-600 to-purple-600 p-8 rounded-2xl (paired with dark:from-indigo-500 dark:to-purple-500) |
| Toggle / chip with selected state | states: isSelected ? {'selected'} : const {}; className uses selected:bg-blue-500 selected:text-white dark:selected:bg-blue-400 dark:selected:text-gray-900 |
| Disabled secondary action | WButton(disabled: true, className: 'disabled:opacity-50 disabled:cursor-not-allowed', ...) |
| Loading button | WButton(isLoading: _isSubmitting, className: 'loading:bg-blue-400 ...', child: const Text('Save')) |
| Avatar with fallback | WImage(src: user.avatarUrl, className: 'w-12 h-12 rounded-full object-cover', errorBuilder: (_, __, ___) => WIcon(Icons.person_outlined, className: 'text-gray-400')) |
| Form field error styling | className includes error:border-red-500 error:ring-1 error:ring-red-500: auto-activates when validator returns non-null |
| Popover menu | WPopover(triggerBuilder: (_, isOpen, __) => WButton(...), contentBuilder: (_, close) => WDiv(...), alignment: PopoverAlignment.bottomRight) |
| Per-breakpoint widget swap | WBreakpoint(base: (_) => MobileLayout(), md: (_) => TabletLayout(), lg: (_) => DesktopLayout()) |
| Status badge / pill | `WDiv(className: 'flex flex-r |
…(truncated)