From 57839086c0e067a20d50a37bfce1e433051aa5ab Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Tue, 7 Jul 2026 13:54:35 +0300 Subject: [PATCH 1/4] test(recipe): prove caller className appends and parser last-wins resolves it (WIND-1) --- test/parser/parsers/sizing_parser_test.dart | 21 +++++++++++++++++++++ test/recipe/wind_recipe_test.dart | 18 ++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/test/parser/parsers/sizing_parser_test.dart b/test/parser/parsers/sizing_parser_test.dart index a1958df..7c716c2 100644 --- a/test/parser/parsers/sizing_parser_test.dart +++ b/test/parser/parsers/sizing_parser_test.dart @@ -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']; diff --git a/test/recipe/wind_recipe_test.dart b/test/recipe/wind_recipe_test.dart index 633f1a9..0afd70c 100644 --- a/test/recipe/wind_recipe_test.dart +++ b/test/recipe/wind_recipe_test.dart @@ -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', From 2a7076deb67918e3f049293675f0545a9c576cf6 Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Tue, 7 Jul 2026 13:54:35 +0300 Subject: [PATCH 2/4] docs(styling): document the recipe caller-append contract, no twMerge (WIND-1) --- CHANGELOG.md | 4 ++++ doc/styling/wind-recipe.md | 10 ++++++++++ skills/wind-ui/SKILL.md | 2 ++ 3 files changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f28a0c..904206a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/doc/styling/wind-recipe.md b/doc/styling/wind-recipe.md index eab548c..b8b6d5d 100644 --- a/doc/styling/wind-recipe.md +++ b/doc/styling/wind-recipe.md @@ -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. + +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. + ## Same-Granularity Override Contract diff --git a/skills/wind-ui/SKILL.md b/skills/wind-ui/SKILL.md index 9eea9e7..132f13f 100644 --- a/skills/wind-ui/SKILL.md +++ b/skills/wind-ui/SKILL.md @@ -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 | From 3761e8b8362bcfc0e3563268102bc3482a67bc59 Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Tue, 7 Jul 2026 17:25:24 +0300 Subject: [PATCH 3/4] docs(styling): cite the parser for parse-time resolution and keep the note consumer-agnostic Addresses PR review: the parse-time-resolution sentence now cites WindParser.findAndGroupClasses / SizingParser.parse (the recipe rationale reference moves to the no-twMerge sentence where it belongs), and the className-REPLACE anti-pattern note no longer names a specific downstream project. --- doc/styling/wind-recipe.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/styling/wind-recipe.md b/doc/styling/wind-recipe.md index b8b6d5d..c48e40f 100644 --- a/doc/styling/wind-recipe.md +++ b/doc/styling/wind-recipe.md @@ -142,11 +142,11 @@ A `null` value in `variants:` clears the default for that axis (no class emitted `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. +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 `wind_recipe.dart:71-78`). 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. +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. ## Same-Granularity Override Contract From 3a760f3c929b599fff932559ec51412de0457ee1 Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Tue, 7 Jul 2026 17:36:47 +0300 Subject: [PATCH 4/4] docs(styling): reference wind_recipe.dart by file, not a drift-prone line range --- doc/styling/wind-recipe.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/styling/wind-recipe.md b/doc/styling/wind-recipe.md index c48e40f..d89ccee 100644 --- a/doc/styling/wind-recipe.md +++ b/doc/styling/wind-recipe.md @@ -142,7 +142,7 @@ A `null` value in `variants:` clears the default for that axis (no class emitted `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 `wind_recipe.dart:71-78`). +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).