Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.

## [Unreleased]

### Changed

- Documented and test-proved the `WindRecipe`/`WindSlotRecipe` caller-`className` merge contract: the recipe only appends the caller's className last (already the behavior since introduction); the conflict with a base token is resolved one layer down, at parse time, by `WindParser`'s per-family last-wins. No twMerge/`cn` port was added or is planned. (`test/recipe/wind_recipe_test.dart`, `test/parser/parsers/sizing_parser_test.dart`, `doc/styling/wind-recipe.md`, `skills/wind-ui/SKILL.md`)

### Added

- `cursor-*` utilities: a new `CursorParser` maps Tailwind cursor names (`cursor-pointer`, `cursor-not-allowed`, `cursor-text`, `cursor-grab`/`cursor-grabbing`, `cursor-zoom-in`/`cursor-zoom-out`, the full resize set, etc.) to the matching `SystemMouseCursors`. `WDiv` applies the resolved cursor through a `MouseRegion`, so a plain container can feel clickable on web/desktop without `WAnchor` or a manual `MouseRegion`; on a `hover:`/`focus:`/`active:` `WDiv` the cursor `MouseRegion` sits below the auto-wrapped `WAnchor` and wins. Inert on touch platforms; last token wins; unknown names no-op. (`lib/src/parser/parsers/cursor_parser.dart`, `lib/src/parser/wind_style.dart`, `lib/src/widgets/w_div.dart`) (#125)
Expand Down
10 changes: 10 additions & 0 deletions doc/styling/wind-recipe.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,16 @@ The caller's `className` / `classNames` always arrive last, so they override ear

A `null` value in `variants:` clears the default for that axis (no class emitted for it), matching `tv()`'s `undefined`-clears semantics.

### The Caller-Append Contract (No twMerge)

`WindRecipe` and `WindSlotRecipe` only APPEND the caller's `className` / `classNames`; they never dedupe, sort, or resolve a conflict themselves. A caller passing a className that conflicts with the base (e.g. `WindRecipe(base: 'w-1/2')(className: 'w-full')`) gets both tokens in the emitted string, in that order: `'w-1/2 w-full'`.

The conflict is resolved one layer down, when the string reaches `WindParser`: `WindParser.findAndGroupClasses` groups same-family classes together WITHOUT deduping, and each parser (e.g. `SizingParser.parse`) resolves them with last-class-wins. So the appended `w-full` wins over the base `w-1/2` at parse time, not at recipe-call time. There is intentionally no Dart port of `tailwind-merge`: wind's parse-time per-family last-wins already achieves the same outcome for every token family the parsers understand, which is why `WindRecipe` deliberately only appends (see the no-twMerge rationale in the `WindRecipe` doc comment in `lib/src/recipe/wind_recipe.dart`).

This is verified by `test/recipe/wind_recipe_test.dart` (proves the append, order preserved) and `test/parser/parsers/sizing_parser_test.dart` (proves the appended `w-full` resolves to the winning style).

Note: the className-REPLACE anti-pattern (a component treating an incoming `className` as a full recipe bypass instead of appending it) is a consumer-component bug, not a wind defect; wind's own recipes always append. When it appears, it lives in a downstream consumer's component layer, outside this package.

<a name="same-granularity-override-contract"></a>
## Same-Granularity Override Contract

Expand Down
2 changes: 2 additions & 0 deletions skills/wind-ui/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ Resolution order (strict, never sorted): `base ++ variant-classes(definition ord

**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 |
Expand Down
21 changes: 21 additions & 0 deletions test/parser/parsers/sizing_parser_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,27 @@ void main() {
expect(styles.height, 8);
});

test(
'a caller-appended w-full wins over a base w-1/2 (WIND-1 proof: '
'per-family last-wins resolves an appended className without '
'twMerge)',
() {
// Mirrors WindRecipe(base: 'w-1/2')(className: 'w-full'): the
// recipe only APPENDS (see wind_recipe_test.dart), both tokens
// reach the parser in order, and reverse-iteration last-wins here
// picks the trailing (caller) token.
final classes = WindParser.findAndGroupClasses(
'w-1/2 w-full',
context,
)['sizing']!;
expect(classes, ['w-1/2', 'w-full']);

final styles = parser.parse(WindStyle(), classes, context);

expect(styles.widthFactor, 1.0);
},
);

test('parses max-width classes correctly', () {
final styles = WindStyle();
final classes = ['max-w-300', 'max-w-screen'];
Expand Down
18 changes: 18 additions & 0 deletions test/recipe/wind_recipe_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,24 @@ void main() {
expect(recipe(className: 'p-2'), 'p-2 p-4 p-2');
});

test(
'a caller className conflicting with the base is appended last, not '
'merged (WIND-1 proof: the caller-append contract has no twMerge step)',
() {
// The base sets a width via w-1/2; the caller passes a conflicting
// w-full. WindRecipe does not resolve this itself: it only appends.
// The emitted string must carry BOTH tokens, caller last, so wind's
// parser (per-family last-wins at parse-time) can pick the winner.
// See test/parser/parsers/sizing_parser_test.dart for the matching
// parser-level proof that w-full actually wins.
final recipe = WindRecipe(base: 'w-1/2');

final result = recipe(className: 'w-full');

expect(result, 'w-1/2 w-full');
},
);

test('throws when a selected axis is unknown', () {
final recipe = WindRecipe(
base: 'btn',
Expand Down