windctrl 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,7 +11,7 @@ It evolves the concept of Variant APIs (like [cva](https://cva.style/)) by intro
11
11
  - 🎨 **Unified API** - Seamlessly blends static Tailwind classes and dynamic inline styles into one cohesive interface.
12
12
  - 🧩 **Trait System** - Solves combinatorial explosion by treating states as stackable, non-exclusive layers.
13
13
  - 🎯 **Scoped Styling** - Context-aware styling using data attributes - no React Context required (RSC friendly).
14
- - ⚡ **JIT Optimized** - Prevents CSS bundle bloat by intelligently routing arbitrary values to inline styles.
14
+ - ⚡ **JIT Conscious** - Designed for Tailwind JIT: utilities stay as class strings, while truly dynamic values can be expressed as inline styles.
15
15
  - 🔒 **Type-Safe** - Best-in-class TypeScript support with automatic prop inference.
16
16
  - 📦 **Minimal Overhead** - Ultra-lightweight runtime with only `clsx` and `tailwind-merge` as dependencies.
17
17
 
@@ -24,28 +24,28 @@ npm install windctrl
24
24
  ## Quick Start
25
25
 
26
26
  ```typescript
27
- import { windCtrl } from "windctrl";
27
+ import { windctrl } from "windctrl";
28
28
 
29
- const button = windCtrl({
30
- base: "rounded px-4 py-2 font-medium transition",
29
+ const button = windctrl({
30
+ base: "rounded px-4 py-2 font-medium transition duration-200",
31
31
  variants: {
32
32
  intent: {
33
33
  primary: "bg-blue-500 text-white hover:bg-blue-600",
34
- secondary: "bg-gray-500 text-gray-900 hover:bg-gray-600",
34
+ secondary: "bg-gray-200 text-gray-900 hover:bg-gray-300",
35
35
  },
36
36
  size: {
37
- sm: "text-sm",
38
- md: "text-base",
39
- lg: "text-lg",
37
+ sm: "text-sm h-8",
38
+ md: "text-base h-10",
39
+ lg: "text-lg h-12",
40
40
  },
41
41
  },
42
42
  traits: {
43
- loading: "opacity-50 cursor-wait",
44
- glass: "backdrop-blur bg-white/10",
43
+ loading: "opacity-70 cursor-wait pointer-events-none",
44
+ glass: "backdrop-blur-md bg-white/10 border border-white/20 shadow-xl",
45
45
  },
46
46
  dynamic: {
47
47
  w: (val) =>
48
- typeof val === "number" ? { style: { width: `${val}px` } } : `w-${val}`,
48
+ typeof val === "number" ? { style: { width: `${val}px` } } : val,
49
49
  },
50
50
  defaultVariants: {
51
51
  intent: "primary",
@@ -53,100 +53,132 @@ const button = windCtrl({
53
53
  },
54
54
  });
55
55
 
56
- // Usage
57
- const { className, style } = button({
58
- intent: "primary",
59
- size: "lg",
60
- traits: ["loading", "glass"],
61
- w: 200,
62
- });
56
+ // Usage Example
57
+
58
+ // 1. Standard usage
59
+ button({ intent: "primary", size: "lg" });
60
+
61
+ // 2. Using Traits (Stackable states)
62
+ button({ traits: ["glass", "loading"] });
63
+
64
+ // 3. Unified API for dynamic values
65
+ // Pass a number for arbitrary px value (Inline Style)
66
+ button({ w: 350 });
67
+ // Pass a string for Tailwind utility (Static Class)
68
+ button({ w: "w-full" });
63
69
  ```
64
70
 
65
71
  ## Core Concepts
66
72
 
67
- ### Dynamic Props (Interpolated Variants)
73
+ ### Interpolated Variants (Dynamic Props)
74
+
75
+ Interpolated variants provide a **Unified API** that bridges static Tailwind classes and dynamic inline styles. A dynamic resolver can return either:
68
76
 
69
- Dynamic props allow you to pass arbitrary values that can resolve to either Tailwind classes or inline styles, bridging the gap between static classes and dynamic styles.
77
+ - a **Tailwind class string** (static utility), or
78
+ - an object containing **className and/or style** (inline styles and optional utilities)
79
+
80
+ This is **JIT-friendly by design**, as long as the class strings you return are statically enumerable (i.e. appear in your source code).
81
+ For truly unbounded values (e.g. pixel sizes), prefer returning style to avoid relying on arbitrary-value class generation.
70
82
 
71
83
  ```typescript
72
- const button = windCtrl({
84
+ const button = windctrl({
73
85
  dynamic: {
86
+ // Recommended pattern:
87
+ // - Numbers -> inline styles (unbounded values)
88
+ // - Strings -> Tailwind utilities (must be statically enumerable for JIT)
74
89
  w: (val) =>
75
- typeof val === "number" ? { style: { width: `${val}px` } } : `w-${val}`,
76
- h: (val) =>
77
- typeof val === "number" ? { style: { height: `${val}px` } } : `h-${val}`,
90
+ typeof val === "number" ? { style: { width: `${val}px` } } : val,
78
91
  },
79
92
  });
80
93
 
81
94
  // Usage
82
- button({ w: "full" }); // Returns className: "w-full"
83
- button({ w: 200 }); // Returns style: { width: "200px" }
84
- button({ w: 200, h: 100 }); // Returns both className and style
95
+ button({ w: "w-full" }); // -> className includes "w-full" (static utility)
96
+ button({ w: 200 }); // -> style includes { width: "200px" } (dynamic value)
85
97
  ```
86
98
 
87
- ### Traits
99
+ > **Note on Tailwind JIT**: Tailwind only generates CSS for class names it can statically detect in your source. Avoid constructing class strings dynamically (e.g. "`w-`" + `size`) unless you safelist them in your Tailwind config.
100
+
101
+ ### Traits (Stackable States)
88
102
 
89
- Traits are stackable, non-exclusive states. Unlike variants, multiple traits can be active simultaneously, solving the combinatorial explosion problem of `compoundVariants`.
103
+ Traits are non-exclusive, stackable layers of state. Unlike `variants` (which are mutually exclusive), multiple traits can be active simultaneously. This declarative approach solves the "combinatorial explosion" problem often seen with `compoundVariants`.
104
+
105
+ Traits are **non-exclusive, stackable modifiers**. Unlike variants (mutually exclusive design choices), multiple traits can be active at the same time. This is a practical way to model boolean-like component states (e.g. `loading`, `disabled`, `glass`) without exploding compoundVariants.
106
+
107
+ When multiple traits generate conflicting utilities, Tailwind’s “last one wins” rule applies (via `tailwind-merge`).
108
+ If ordering matters, prefer the **array form** to make precedence explicit.
90
109
 
91
110
  ```typescript
92
- const button = windCtrl({
111
+ const button = windctrl({
93
112
  traits: {
94
113
  loading: "opacity-50 cursor-wait",
95
- glass: "backdrop-blur bg-white/10",
96
- disabled: "pointer-events-none",
114
+ glass: "backdrop-blur-md bg-white/10 border border-white/20",
115
+ disabled: "pointer-events-none grayscale",
97
116
  },
98
117
  });
99
118
 
100
- // Usage - Array form
119
+ // Usage - Array form (explicit precedence; recommended when conflicts are possible)
101
120
  button({ traits: ["loading", "glass"] });
102
121
 
103
- // Usage - Object form
104
- button({ traits: { loading: true, glass: true, disabled: false } });
122
+ // Usage - Object form (convenient for boolean props; order is not intended to be meaningful)
123
+ button({ traits: { loading: isLoading, glass: true } });
105
124
  ```
106
125
 
107
- ### Scopes
126
+ ### Scopes (RSC Support)
108
127
 
109
- Scopes provide context-aware styling without React Context, making it fully compatible with Server Components (RSC).
128
+ Scopes enable **context-aware styling** without relying on React Context or client-side JavaScript. This makes them fully compatible with React Server Components (RSC). They utilize Tailwind's group modifier logic under the hood.
110
129
 
111
130
  ```typescript
112
- const button = windCtrl({
131
+ const button = windctrl({
113
132
  scopes: {
114
- header: "text-sm",
115
- footer: "text-xs",
133
+ header: "text-sm py-1",
134
+ footer: "text-xs text-gray-500",
116
135
  },
117
136
  });
118
137
 
119
- // Usage - Wrap elements with data-scope attribute
138
+ // Usage
139
+ // 1. Wrap the parent with `data-scope` and `group/wind-scope`
140
+ // 2. The button automatically adapts its style based on the parent
120
141
  <div data-scope="header" className="group/wind-scope">
121
- <button className={button({}).className}>Header Button</button>
142
+ <button className={button().className}>Header Button</button>
122
143
  </div>
123
144
  ```
124
145
 
125
- The scope classes are automatically prefixed with `group-data-[scope=...]/wind-scope:` to work with Tailwind's group modifier.
146
+ The scope classes are automatically prefixed with `group-data-[scope=...]/wind-scope:` to target the parent's data attribute.
126
147
 
127
148
  ### Variants
128
149
 
129
- Variants are mutually exclusive options. Each variant dimension can have multiple options, but only one option per dimension can be active at a time.
150
+ Variants represent mutually exclusive design choices (e.g., `primary` vs `secondary`). They serve as the foundation of your component's design system.
130
151
 
131
152
  ```typescript
132
- const button = windCtrl({
153
+ const button = windctrl({
133
154
  variants: {
134
155
  intent: {
135
- primary: "bg-blue-500",
136
- secondary: "bg-gray-500",
156
+ primary: "bg-blue-500 text-white hover:bg-blue-600",
157
+ secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200",
137
158
  },
138
159
  size: {
139
- sm: "text-sm",
140
- md: "text-base",
141
- lg: "text-lg",
160
+ sm: "text-sm h-8 px-3",
161
+ md: "text-base h-10 px-4",
162
+ lg: "text-lg h-12 px-6",
142
163
  },
143
164
  },
165
+ defaultVariants: {
166
+ intent: "primary",
167
+ size: "md",
168
+ },
144
169
  });
145
170
 
146
171
  // Usage
147
172
  button({ intent: "primary", size: "lg" });
148
173
  ```
149
174
 
175
+ ## Gotchas
176
+
177
+ - **Tailwind JIT:** Tailwind only generates CSS for class names it can statically detect. Avoid constructing class strings dynamically unless you safelist them.
178
+ - **Traits precedence:** If trait order matters, use the array form (`traits: ["a", "b"]`) to make precedence explicit.
179
+ - **SSR/RSC:** Keep dynamic resolvers pure (same input → same output) to avoid hydration mismatches.
180
+ - **Static config:** `windctrl` configuration is treated as static/immutable. Mutating the config object after creation is not supported.
181
+
150
182
  ## License
151
183
 
152
184
  The MIT License (MIT)
package/dist/index.d.ts CHANGED
@@ -26,5 +26,6 @@ type Result = {
26
26
  className: string;
27
27
  style?: CSSProperties;
28
28
  };
29
- export declare function windCtrl<TVariants extends Record<string, Record<string, ClassValue>> = {}, TTraits extends Record<string, ClassValue> = {}, TDynamic extends Record<string, DynamicResolver> = {}, TScopes extends Record<string, ClassValue> = {}>(config: Config<TVariants, TTraits, TDynamic, TScopes>): (props?: Props<TVariants, TTraits, TDynamic>) => Result;
29
+ export declare function windctrl<TVariants extends Record<string, Record<string, ClassValue>> = {}, TTraits extends Record<string, ClassValue> = {}, TDynamic extends Record<string, DynamicResolver> = {}, TScopes extends Record<string, ClassValue> = {}>(config: Config<TVariants, TTraits, TDynamic, TScopes>): (props?: Props<TVariants, TTraits, TDynamic>) => Result;
30
+ export declare const wc: typeof windctrl;
30
31
  export {};
package/dist/index.js CHANGED
@@ -18,10 +18,10 @@ function processTraits(traits, propsTraits) {
18
18
  }
19
19
  return [];
20
20
  }
21
- function processDynamic(dynamic, props) {
21
+ function processDynamicEntries(entries, props) {
22
22
  const classNameParts = [];
23
23
  const styles = [];
24
- for (const [key, resolver] of Object.entries(dynamic)) {
24
+ for (const [key, resolver] of entries) {
25
25
  const value = props[key];
26
26
  if (value !== undefined && value !== null) {
27
27
  const result = resolver(value);
@@ -53,8 +53,11 @@ function processScopes(scopes) {
53
53
  .join(" ");
54
54
  });
55
55
  }
56
- export function windCtrl(config) {
56
+ export function windctrl(config) {
57
57
  const { base, variants = {}, traits = {}, dynamic = {}, scopes = {}, defaultVariants = {}, } = config;
58
+ const resolvedVariants = Object.entries(variants);
59
+ const resolvedDynamicEntries = Object.entries(dynamic);
60
+ const resolvedScopeClasses = processScopes(scopes);
58
61
  return (props = {}) => {
59
62
  const classNameParts = [];
60
63
  let mergedStyle = {};
@@ -65,30 +68,26 @@ export function windCtrl(config) {
65
68
  classNameParts.push(base);
66
69
  }
67
70
  // 2. Variants (with defaultVariants fallback)
68
- if (variants) {
69
- for (const [variantKey, variantOptions] of Object.entries(variants)) {
70
- const propValue = props[variantKey] ??
71
- defaultVariants[variantKey];
72
- if (propValue && variantOptions[propValue]) {
73
- classNameParts.push(variantOptions[propValue]);
74
- }
71
+ for (const [variantKey, variantOptions] of resolvedVariants) {
72
+ const propValue = props[variantKey] ??
73
+ defaultVariants[variantKey];
74
+ if (propValue && variantOptions[propValue]) {
75
+ classNameParts.push(variantOptions[propValue]);
75
76
  }
76
77
  }
77
78
  // 3. Traits (higher priority than variants)
78
- if (traits && props.traits) {
79
- const traitClasses = processTraits(traits, props.traits);
80
- classNameParts.push(...traitClasses);
79
+ if (props.traits) {
80
+ classNameParts.push(...processTraits(traits, props.traits));
81
81
  }
82
82
  // 4. Dynamic (highest priority for className)
83
- if (dynamic) {
84
- const dynamicResult = processDynamic(dynamic, props);
83
+ if (resolvedDynamicEntries.length) {
84
+ const dynamicResult = processDynamicEntries(resolvedDynamicEntries, props);
85
85
  classNameParts.push(...dynamicResult.className);
86
86
  mergedStyle = mergeStyles(mergedStyle, dynamicResult.style);
87
87
  }
88
88
  // 5. Scopes (always applied, but don't conflict with other classes)
89
- if (scopes) {
90
- const scopeClasses = processScopes(scopes);
91
- classNameParts.push(...scopeClasses);
89
+ if (resolvedScopeClasses.length) {
90
+ classNameParts.push(...resolvedScopeClasses);
92
91
  }
93
92
  const finalClassName = twMerge(clsx(classNameParts));
94
93
  const hasStyle = Object.keys(mergedStyle).length > 0;
@@ -98,3 +97,4 @@ export function windCtrl(config) {
98
97
  };
99
98
  };
100
99
  }
100
+ export const wc = windctrl;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "windctrl",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
4
4
  "description": "Advanced variant API for Tailwind CSS with stackable traits and interpolated dynamic styles.",
5
5
  "license": "MIT",
6
6
  "author": "Masaki Morishita (@morishxt)",
@@ -34,7 +34,7 @@
34
34
  ],
35
35
  "repository": {
36
36
  "type": "git",
37
- "url": "https://github.com/morishxt/windctrl.git"
37
+ "url": "git+https://github.com/morishxt/windctrl.git"
38
38
  },
39
39
  "bugs": {
40
40
  "url": "https://github.com/morishxt/windctrl/issues"
@@ -42,8 +42,6 @@
42
42
  "homepage": "https://github.com/morishxt/windctrl#readme",
43
43
  "scripts": {
44
44
  "test": "vitest",
45
- "test:watch": "vitest --watch",
46
- "test:coverage": "vitest --coverage",
47
45
  "build": "tsc",
48
46
  "dev": "tsc --watch",
49
47
  "format": "prettier --write .",