Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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`: `findAndGroupClasses` groups same-family classes together WITHOUT deduping, and each parser resolves them with last-class-wins (see `wind_recipe.dart:71-78`). 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.
Comment thread
anilcancakir marked this conversation as resolved.
Outdated

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. That defect lives in `magic_starter`'s component layer, outside this package.
Comment thread
anilcancakir marked this conversation as resolved.
Outdated

<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