# Wind UI

> fluttersdk_wind 1.5: 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, Di

- Skill: `fluttersdk/wind-ui` (Agent Skill, multi-file: 11 files)
- Install (CLI): `npx skillmds@latest add fluttersdk/wind-ui`
- Raw SKILL.md: https://api.skillmd.com/api/skills/fluttersdk/wind-ui/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: fluttersdk (https://skillmd.com/u/fluttersdk)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/fluttersdk/wind-ui

---


<!-- fluttersdk_wind 1.5.x | Skill v2.14.0 (2026-09-08) -->

# 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](#13-quick-install) at the bottom.

## 0. Before writing UI in this project

Three quick checks. Each pays off across the whole session.

1. **Confirm Wind is installed.** Look at `pubspec.yaml` for `fluttersdk_wind:`. If absent, jump to §13 first.
2. **Read the project's `WindThemeData` setup.** Usually in `lib/main.dart` or `lib/config/wind.dart`. Note any custom color families (`primary`, `accent`, `incident`, etc.): those become available as `bg-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.
3. **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.

1. **className is the styling surface.** Inline Dart props (`backgroundColor` on `WDiv`, `foregroundColor` on `WText`) exist only as runtime-dynamic escape hatches for values the cache key cannot represent. Default to className. Never reach for `BoxDecoration`, `EdgeInsets`, `TextStyle` when a token covers it.

2. **Every `bg-` / `text-` / `border-` / `ring-` / `shadow-` / `fill-` carries a `dark:` peer in the same className.** Missing pair is a bug, not a style choice. Pair `bg-white dark:bg-gray-800` on 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.

3. **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:

   ```dart
   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: ...,
   );
   ```

4. **Inside a Row (`flex flex-row`), prefer `flex-1` for a fill-the-row child.** A bare `w-full` on a direct Row child is now treated as `flex-1` (the row wraps it in `Expanded`), so it fills the available width instead of asserting `RenderBox was not laid out`; `flex-1` stays the idiomatic, explicit choice and is what to reach for. (A prefixed `md:w-full` is NOT auto-expanded; use `md:flex-1`.) Inside a Column (`flex flex-col`), scrollable children use `flex-1 overflow-y-auto` plus the constructor prop `scrollPrimary: true` for iOS tap-to-top. `h-full` inside a scrollable parent still triggers "Vertical viewport was given unbounded height".

5. **`child` XOR `children` on every W-widget that accepts both.** Passing both fails an assertion at construction. Passing neither renders an empty `SizedBox`.

6. **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-4` logical-inline, `-m-4` negative margin, a mistyped family like `wibble-4`) is dropped and, in `kDebugMode`, prints a one-time `debugPrint` naming 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's `text-6xl` cap; `flex-cow`, which still matches `flex-*`) 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 load `references/tokens.md` to verify the family.

7. **Last class wins within a parser family.** `p-4 p-8` resolves to `p-8`. `bg-red-500 bg-blue-500` resolves to `bg-blue-500`. Conflicts inside the same property are stable but silent; conflicts across properties (`text-red-500` color + `text-center` alignment) coexist because they target different fields.

8. **`WindTheme` lives BELOW `MaterialApp` in the runtime tree.** The builder pattern inverts apparent order: `WindTheme(data: ..., builder: (ctx, controller) => MaterialApp(...))`. Consequence: `OverlayEntry.builder` contexts cannot reach `WindTheme` via ancestor walk. Capture the State's `context` before showing an overlay, then pass it to `WindParser.parse` from inside the overlay builder. `WPopover` / `WSelect` already handle this internally.

9. **Wind composes with Flutter, not against it.** `Scaffold`, `AppBar`, `Dialog`, `BottomSheet`, `Drawer`, `SnackBar`, `Navigator`, `Hero`, `FutureBuilder`, `StreamBuilder`, `ValueListenableBuilder` remain canonical. `ListView` / `GridView.builder` / `CustomScrollView` are the right choice for virtualised lists; `WDiv` with `grid-cols-N` produces a static `Wrap` (or, with `items-stretch`, equal-height rows), not a virtualised grid. See [Wind ≠ Flutter rules of thumb](#9-wind--flutter-rules-of-thumb).

10. **`active:` prefix is reserved but not wired.** `WAnchor` tracks hover and focus only; there is no onTapDown/onTapUp tracking. Don't rely on `active:bg-blue-700` for press feedback. Use a transient state in the consumer's controller and `states: {'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.

```dart
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):

```dart
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):

```dart
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:

```dart
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:

1. **Every color token has a `dark:` peer** in the same className block. Grep your diff for `bg-`, `text-`, `border-`, `ring-`, `fill-`, `shadow-` and confirm a `dark:` peer on each.
2. **Every multi-concern className (3+ token families) is triple-quoted**, one concern per line, dark pairs grouped beside their light variant.
3. **Every interactive surface has `hover:` and `focus:` states** (web/desktop targets) on `WButton`, `WAnchor`, `WInput`, `WSelect`, `WCheckbox`, `WDatePicker`, plus `disabled:` styling wherever the widget exposes an `enabled` / `disabled` parameter.
4. **Every `child` XOR `children`**: never both. Construction-time assertion.
5. **Every scrollable root `WDiv` has `scrollPrimary: true`** on at least one ancestor in the scroll chain so iOS status-bar-tap scrolls to top.
6. **No Dart string interpolation inside className**, no inline `BoxDecoration` / `TextStyle` / `EdgeInsets` when a token covers it, no `Icons.*` (non-outlined) without a deliberate reason, no `WIcon` without `Icons.*_outlined`.
7. **Every icon-only `WButton` / `WAnchor` has a `semanticLabel`**: without it the control is nameless to screen readers and `getByRole('button', { name })`. When set, the child subtree is excluded from semantics, so `semanticLabel` overrides 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)
