@ntatoud/styra 0.1.0 → 0.2.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
@@ -36,6 +36,14 @@ const btn = styra("btn")
36
36
  .compound([{ disabled: { not: "yes" }, class: "hover:opacity-80" }]);
37
37
  ```
38
38
 
39
+ ## AI Agent Support
40
+
41
+ If you use an AI agent (Claude Code, Cursor, Copilot, etc.), run the following to install styra's intent skills:
42
+
43
+ ```bash
44
+ npx @tanstack/intent@latest install
45
+ ```
46
+
39
47
  ## Development
40
48
 
41
49
  - Install dependencies:
package/dist/index.d.mts CHANGED
@@ -1,5 +1,7 @@
1
1
  //#region src/types.d.ts
2
2
  type MergeFn = (...classes: string[]) => string;
3
+ /** A clsx-compatible class value: string, number, boolean, null, undefined, array, or object map. */
4
+ type ClassValue = string | number | boolean | null | undefined | ClassValue[] | Record<string, unknown>;
3
5
  /**
4
6
  * A map of variant keys to their possible values and classes.
5
7
  * Values can be a `Record<string, string>` (explicit map) or a plain `string`
@@ -26,14 +28,14 @@ type CompoundRule<V extends VariantMap> = CompoundCondition<V> & {
26
28
  * - Variants without a default → required
27
29
  */
28
30
  type InferProps<V extends VariantMap, D extends DefaultsOf<V>> = { [K in keyof V as K extends keyof D ? never : K]: PropTypeOf<V[K]> } & { [K in keyof V as K extends keyof D ? K : never]?: PropTypeOf<V[K]> } & {
29
- class?: string;
30
- className?: string | ((...args: never[]) => string | undefined);
31
+ class?: ClassValue;
32
+ className?: ClassValue | ((...args: never[]) => ClassValue);
31
33
  };
32
34
  /** A callable builder that also exposes `.variants()`, `.defaults()`, `.compound()`. */
33
35
  interface StyraBuilder<V extends VariantMap, D extends DefaultsOf<V>> {
34
36
  (props: keyof V extends never ? {
35
- class?: string;
36
- className?: string | ((...args: never[]) => string | undefined);
37
+ class?: ClassValue;
38
+ className?: ClassValue | ((...args: never[]) => ClassValue);
37
39
  } : InferProps<V, D>): string;
38
40
  /**
39
41
  * Define variant keys and their class mappings.
@@ -70,13 +72,14 @@ type VariantProps<T extends (...args: never[]) => string> = Omit<Parameters<T>[0
70
72
  * import { createStyra } from 'styra'
71
73
  * import { twMerge } from 'tailwind-merge'
72
74
  *
73
- * export const { styra } = createStyra({ merge: twMerge })
75
+ * export const { styra, cn } = createStyra({ merge: twMerge })
74
76
  * ```
75
77
  */
76
78
  declare function createStyra(options?: StyraOptions): {
77
79
  styra: (base: string) => StyraBuilder<Record<never, never>, Record<never, never>>;
80
+ cn: (...args: ClassValue[]) => string;
78
81
  };
79
82
  /** Default `styra` instance with no custom merge function. */
80
- declare const styra: (base: string) => StyraBuilder<Record<never, never>, Record<never, never>>;
83
+ declare const styra: (base: string) => StyraBuilder<Record<never, never>, Record<never, never>>, cn: (...args: ClassValue[]) => string;
81
84
  //#endregion
82
- export { type CompoundRule, type InferProps, type StyraBuilder, type StyraOptions, type VariantMap, type VariantProps, createStyra, styra };
85
+ export { type ClassValue, type CompoundRule, type InferProps, type StyraBuilder, type StyraOptions, type VariantMap, type VariantProps, cn, createStyra, styra };
package/dist/index.mjs CHANGED
@@ -10,10 +10,30 @@ function matchesCompound(rule, resolved) {
10
10
  }
11
11
  return true;
12
12
  }
13
- /** Resolve a className prop that may be a string or a render-prop function. */
13
+ /** Recursively resolve a clsx-like value to a string. */
14
+ function toVal(mix) {
15
+ let str = "";
16
+ if (typeof mix === "string" || typeof mix === "number") str += mix;
17
+ else if (Array.isArray(mix)) {
18
+ for (let k = 0; k < mix.length; k++) if (mix[k]) {
19
+ const y = toVal(mix[k]);
20
+ if (y) {
21
+ str && (str += " ");
22
+ str += y;
23
+ }
24
+ }
25
+ } else if (mix !== null && typeof mix === "object") {
26
+ for (const y in mix) if (mix[y]) {
27
+ str && (str += " ");
28
+ str += y;
29
+ }
30
+ }
31
+ return str;
32
+ }
33
+ /** Resolve a className prop that may be a clsx-like value or a render-prop function. */
14
34
  function resolveClassName(v) {
15
- if (typeof v === "string") return v || void 0;
16
- if (typeof v === "function") return v() || void 0;
35
+ if (typeof v === "function") return toVal(v()) || void 0;
36
+ return toVal(v) || void 0;
17
37
  }
18
38
  /** Coerce a prop value to its string key for map lookup (handles booleans from boolean shorthand). */
19
39
  function toKey(v) {
@@ -117,7 +137,7 @@ function makeBuilder(base, variantMap, defaultMap, compoundRules, customMerge, v
117
137
  * import { createStyra } from 'styra'
118
138
  * import { twMerge } from 'tailwind-merge'
119
139
  *
120
- * export const { styra } = createStyra({ merge: twMerge })
140
+ * export const { styra, cn } = createStyra({ merge: twMerge })
121
141
  * ```
122
142
  */
123
143
  function createStyra(options) {
@@ -125,9 +145,23 @@ function createStyra(options) {
125
145
  function styra(base) {
126
146
  return makeBuilder(base, {}, {}, [], customMerge, false);
127
147
  }
128
- return { styra };
148
+ function cn(...args) {
149
+ let str = "";
150
+ for (let i = 0; i < args.length; i++) {
151
+ const val = toVal(args[i]);
152
+ if (val) {
153
+ str && (str += " ");
154
+ str += val;
155
+ }
156
+ }
157
+ return customMerge ? customMerge(str) : str;
158
+ }
159
+ return {
160
+ styra,
161
+ cn
162
+ };
129
163
  }
130
164
  /** Default `styra` instance with no custom merge function. */
131
- const { styra } = createStyra();
165
+ const { styra, cn } = createStyra();
132
166
  //#endregion
133
- export { createStyra, styra };
167
+ export { cn, createStyra, styra };
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@ntatoud/styra",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Type-safe class variance builder — a maintained, boosted CVA replacement.",
5
5
  "keywords": [
6
6
  "class-variance-authority",
7
7
  "cva",
8
8
  "tailwind",
9
+ "tanstack-intent",
9
10
  "typescript",
10
11
  "variants"
11
12
  ],
@@ -19,7 +20,8 @@
19
20
  "url": "git+https://github.com/ntatoud/styra.git"
20
21
  },
21
22
  "files": [
22
- "dist"
23
+ "dist",
24
+ "skills"
23
25
  ],
24
26
  "type": "module",
25
27
  "exports": {
@@ -34,6 +36,7 @@
34
36
  },
35
37
  "devDependencies": {
36
38
  "@arethetypeswrong/core": "catalog:",
39
+ "@tanstack/intent": "catalog:",
37
40
  "@types/node": "^25.5.0",
38
41
  "typescript": "^6.0.2",
39
42
  "vite-plus": "catalog:",
@@ -0,0 +1,305 @@
1
+ ---
2
+ name: styra-compound-variants
3
+ description: >
4
+ Compound variant rules for @ntatoud/styra v0.1.0 — .compound([rules]) syntax,
5
+ exact-match conditions (all keys must match for rule to fire), Not<T> negation
6
+ ({ variantKey: { not: value } } — fires when variant is NOT that value),
7
+ multiple independent rules (additive), boolean shorthand coercion ("true"/"false"
8
+ string keys in negation). Exports: CompoundRule, Not.
9
+ Preempts: { not: true } instead of { not: "true" } for boolean props,
10
+ missing class key in rule, compoundVariants CVA migration pitfall.
11
+ type: core
12
+ library: "@ntatoud/styra"
13
+ library_version: "0.1.0"
14
+ requires:
15
+ - styra-define-variants
16
+ sources:
17
+ - "ntatoud/styra:packages/styra/src/index.ts"
18
+ - "ntatoud/styra:packages/styra/src/types.ts"
19
+ ---
20
+
21
+ # Compound Variants
22
+
23
+ This skill builds on styra-define-variants. Read it first for foundational concepts.
24
+
25
+ ## 1. Setup
26
+
27
+ Minimum viable example — exact-match rule and negation rule:
28
+
29
+ ```ts
30
+ import { styra, type CompoundRule, type Not } from "@ntatoud/styra";
31
+
32
+ // Exact-match: ring-red only when size=sm AND color=red
33
+ const button = styra("btn")
34
+ .variants({
35
+ size: { sm: "text-sm", md: "text-base", lg: "text-lg" },
36
+ color: { red: "bg-red-500", blue: "bg-blue-500" },
37
+ })
38
+ .compound([{ size: "sm", color: "red", class: "ring-2 ring-red-300" }]);
39
+
40
+ button({ size: "sm", color: "red" });
41
+ // → "btn text-sm bg-red-500 ring-2 ring-red-300"
42
+
43
+ button({ size: "md", color: "red" });
44
+ // → "btn text-base bg-red-500" (size is md, rule doesn't fire)
45
+ ```
46
+
47
+ ```ts
48
+ import { styra } from "@ntatoud/styra";
49
+
50
+ // Negation: hover:opacity-80 on everything EXCEPT when size=sm
51
+ const button = styra("btn")
52
+ .variants({
53
+ size: { sm: "text-sm", md: "text-base", lg: "text-lg" },
54
+ color: { red: "bg-red-500", blue: "bg-blue-500" },
55
+ })
56
+ .compound([{ size: { not: "sm" }, color: "red", class: "hover:opacity-80" }]);
57
+
58
+ button({ size: "md", color: "red" });
59
+ // → "btn text-base bg-red-500 hover:opacity-80"
60
+
61
+ button({ size: "sm", color: "red" });
62
+ // → "btn text-sm bg-red-500" (negation blocks the rule)
63
+ ```
64
+
65
+ ---
66
+
67
+ ## 2. Core Patterns
68
+
69
+ ### Pattern 1 — Multiple independent rules (additive)
70
+
71
+ All rules are evaluated independently. Every matching rule contributes its class.
72
+
73
+ ```ts
74
+ import { styra } from "@ntatoud/styra";
75
+
76
+ const badge = styra("badge")
77
+ .variants({
78
+ size: { sm: "text-sm", lg: "text-lg" },
79
+ color: { red: "bg-red-500", blue: "bg-blue-500" },
80
+ outlined: "border-2",
81
+ })
82
+ .compound([
83
+ { size: "sm", color: "red", class: "ring-red-300" },
84
+ { outlined: "true", color: "red", class: "border-red-500" },
85
+ ]);
86
+
87
+ badge({ size: "sm", color: "red", outlined: true });
88
+ // → "badge text-sm bg-red-500 border-2 ring-red-300 border-red-500"
89
+ // Both rules matched — both classes applied.
90
+
91
+ badge({ size: "lg", color: "red", outlined: true });
92
+ // → "badge text-lg bg-red-500 border-2 border-red-500"
93
+ // Only second rule matched — ring not applied.
94
+ ```
95
+
96
+ ### Pattern 2 — Negation with Not<T>
97
+
98
+ `{ not: value }` makes a condition fire when the variant is anything other than `value`.
99
+
100
+ ```ts
101
+ import { styra } from "@ntatoud/styra";
102
+
103
+ const button = styra("btn")
104
+ .variants({
105
+ size: { sm: "text-sm", md: "text-base", lg: "text-lg" },
106
+ })
107
+ .compound([
108
+ // Apply hover effect on md and lg, but not sm
109
+ { size: { not: "sm" }, class: "hover:brightness-110" },
110
+ ]);
111
+
112
+ button({ size: "md" });
113
+ // → "btn text-base hover:brightness-110"
114
+
115
+ button({ size: "lg" });
116
+ // → "btn text-lg hover:brightness-110"
117
+
118
+ button({ size: "sm" });
119
+ // → "btn text-sm"
120
+ ```
121
+
122
+ ### Pattern 3 — Boolean shorthand with negation
123
+
124
+ Boolean shorthand variants store their state as the string `"true"` or `"false"` internally. Negation must use the string form.
125
+
126
+ ```ts
127
+ import { styra } from "@ntatoud/styra";
128
+
129
+ const button = styra("btn")
130
+ .variants({
131
+ size: { sm: "text-sm", lg: "text-lg" },
132
+ disabled: "opacity-50 cursor-not-allowed",
133
+ })
134
+ .compound([
135
+ // hover effect applies only when disabled is falsy
136
+ { disabled: { not: "true" }, class: "hover:opacity-80" },
137
+ ]);
138
+
139
+ button({ size: "sm" });
140
+ // → "btn text-sm hover:opacity-80" (disabled not set → rule fires)
141
+
142
+ button({ size: "sm", disabled: false });
143
+ // → "btn text-sm hover:opacity-80" (disabled=false → "false", not "true" → rule fires)
144
+
145
+ button({ size: "sm", disabled: true });
146
+ // → "btn text-sm opacity-50 cursor-not-allowed" (disabled=true → "true" → negation blocks rule)
147
+ ```
148
+
149
+ ### Pattern 4 — Using CompoundRule and Not for typed rule arrays
150
+
151
+ When building rules outside the builder call, use the exported generic types.
152
+
153
+ ```ts
154
+ import { styra, type CompoundRule, type Not } from "@ntatoud/styra";
155
+ import type { VariantProps } from "@ntatoud/styra";
156
+
157
+ const button = styra("btn").variants({
158
+ size: { sm: "text-sm", md: "text-base", lg: "text-lg" },
159
+ color: { red: "bg-red-500", blue: "bg-blue-500" },
160
+ });
161
+
162
+ type V = Parameters<typeof button>[0];
163
+
164
+ // CompoundRule<V> enforces that all keys match variant names and class is present
165
+ const rules: CompoundRule<{
166
+ size: { sm: string; md: string; lg: string };
167
+ color: { red: string; blue: string };
168
+ }>[] = [
169
+ { size: "sm", color: "red", class: "ring-red-300" },
170
+ { size: { not: "lg" }, class: "shadow-sm" },
171
+ ];
172
+
173
+ const styledButton = button.compound(rules);
174
+
175
+ styledButton({ size: "sm", color: "red" });
176
+ // → "btn text-sm bg-red-500 ring-red-300 shadow-sm"
177
+ ```
178
+
179
+ ---
180
+
181
+ ## 3. Common Mistakes
182
+
183
+ ### [CRITICAL] Using boolean `true` instead of string `"true"` in negation
184
+
185
+ **Wrong**
186
+
187
+ ```ts
188
+ import { styra } from "@ntatoud/styra";
189
+
190
+ const button = styra("btn")
191
+ .variants({ disabled: "opacity-50" })
192
+ .compound([
193
+ { disabled: { not: true }, class: "hover:opacity-80" }, // not: true — boolean, never matches
194
+ ]);
195
+
196
+ button({ disabled: true });
197
+ // → "btn opacity-50 hover:opacity-80" (unexpected — hover should NOT apply)
198
+ ```
199
+
200
+ **Correct**
201
+
202
+ ```ts
203
+ import { styra } from "@ntatoud/styra";
204
+
205
+ const button = styra("btn")
206
+ .variants({ disabled: "opacity-50" })
207
+ .compound([
208
+ { disabled: { not: "true" }, class: "hover:opacity-80" }, // not: "true" — string key
209
+ ]);
210
+
211
+ button({ disabled: true });
212
+ // → "btn opacity-50" (hover correctly blocked)
213
+ ```
214
+
215
+ Boolean shorthand variants coerce `true`/`false` props to the string keys `"true"`/`"false"` internally. The negation value must match the stored string key, not the JavaScript boolean. Source: `ntatoud/styra:packages/styra/src/index.ts`
216
+
217
+ ---
218
+
219
+ ### [HIGH] Omitting the `class` key in a compound rule
220
+
221
+ **Wrong**
222
+
223
+ ```ts
224
+ import { styra } from "@ntatoud/styra";
225
+
226
+ const button = styra("btn")
227
+ .variants({ size: { sm: "text-sm", lg: "text-lg" }, color: { red: "bg-red-500" } })
228
+ .compound([
229
+ { size: "sm", color: "red" }, // TypeScript error: missing 'class' property
230
+ ]);
231
+ ```
232
+
233
+ **Correct**
234
+
235
+ ```ts
236
+ import { styra } from "@ntatoud/styra";
237
+
238
+ const button = styra("btn")
239
+ .variants({ size: { sm: "text-sm", lg: "text-lg" }, color: { red: "bg-red-500" } })
240
+ .compound([{ size: "sm", color: "red", class: "ring-red-300" }]);
241
+ ```
242
+
243
+ `CompoundRule<V>` requires `class: string`. TypeScript will catch the omission at compile time; at runtime the rule is silently skipped because there is no class to apply. Always include the `class` key. Source: `ntatoud/styra:packages/styra/src/types.ts`
244
+
245
+ ---
246
+
247
+ ### [HIGH] CVA migration: compoundVariants array instead of .compound()
248
+
249
+ **Wrong**
250
+
251
+ ```ts
252
+ import { styra } from "@ntatoud/styra";
253
+
254
+ // CVA-style API — does not exist in styra
255
+ const button = styra("btn", {
256
+ variants: { size: { sm: "text-sm", lg: "text-lg" } },
257
+ compoundVariants: [{ size: "sm", class: "ring-sm" }], // ignored — not a valid option
258
+ });
259
+ ```
260
+
261
+ **Correct**
262
+
263
+ ```ts
264
+ import { styra } from "@ntatoud/styra";
265
+
266
+ const button = styra("btn")
267
+ .variants({ size: { sm: "text-sm", lg: "text-lg" } })
268
+ .compound([{ size: "sm", class: "ring-sm" }]);
269
+ ```
270
+
271
+ Styra uses a builder chain, not a config object. Compound rules are attached via `.compound([...])` on the builder, not a `compoundVariants` key. Source: `ntatoud/styra:packages/styra/src/index.ts`
272
+
273
+ ---
274
+
275
+ ### [MEDIUM] Writing a compound rule when a default or boolean variant is simpler
276
+
277
+ **Wrong**
278
+
279
+ ```ts
280
+ import { styra } from "@ntatoud/styra";
281
+
282
+ // Using compound just to apply a class when a single variant has a specific value
283
+ const button = styra("btn")
284
+ .variants({ intent: { primary: "bg-blue-600", danger: "bg-red-600" } })
285
+ .compound([
286
+ { intent: "primary", class: "text-white" },
287
+ { intent: "danger", class: "text-white" },
288
+ ]);
289
+ ```
290
+
291
+ **Correct**
292
+
293
+ ```ts
294
+ import { styra } from "@ntatoud/styra";
295
+
296
+ // Just include the class in the variant map
297
+ const button = styra("btn").variants({
298
+ intent: {
299
+ primary: "bg-blue-600 text-white",
300
+ danger: "bg-red-600 text-white",
301
+ },
302
+ });
303
+ ```
304
+
305
+ `.compound()` is for cases where a class depends on the intersection of two or more variant values simultaneously. Single-variant class assignments belong directly in the variant map. Source: `ntatoud/styra:packages/styra/src/index.ts`
@@ -0,0 +1,218 @@
1
+ ---
2
+ name: styra-define-variants
3
+ description: >
4
+ Define typed variant maps (size, color, state flags) on a styra builder using
5
+ .variants(), boolean shorthand, .defaults(), and VariantProps. Covers
6
+ class/className ClassValue inputs, optional vs required variant inference, and
7
+ compound-rule key coercion.
8
+ type: core
9
+ library: "@ntatoud/styra"
10
+ library_version: "0.1.0"
11
+ requires:
12
+ - styra-getting-started
13
+ sources:
14
+ - "ntatoud/styra:packages/styra/src/types.ts"
15
+ - "ntatoud/styra:packages/styra/src/index.ts"
16
+ ---
17
+
18
+ This skill builds on styra-getting-started. Read it first for foundational concepts.
19
+
20
+ ## Setup
21
+
22
+ ```ts
23
+ import { styra, type VariantProps, type ClassValue } from "@ntatoud/styra";
24
+
25
+ const buttonVariants = styra("btn")
26
+ .variants({
27
+ size: { sm: "text-sm px-2 py-1", md: "text-base px-4 py-2", lg: "text-lg px-6 py-3" },
28
+ color: { primary: "bg-blue-600 text-white", danger: "bg-red-600 text-white" },
29
+ disabled: "opacity-50 pointer-events-none",
30
+ })
31
+ .defaults({ size: "md" });
32
+
33
+ type ButtonProps = VariantProps<typeof buttonVariants>;
34
+ // → { size?: "sm" | "md" | "lg"; color: "primary" | "danger"; disabled?: boolean }
35
+ ```
36
+
37
+ ## Core Patterns
38
+
39
+ ### Standard variant map
40
+
41
+ Pass an object of string keys to string values. Each key becomes a prop; each value becomes the class applied when that key is selected.
42
+
43
+ ```ts
44
+ import { styra } from "@ntatoud/styra";
45
+
46
+ const badge = styra("badge").variants({
47
+ size: {
48
+ sm: "text-xs px-1",
49
+ md: "text-sm px-2",
50
+ lg: "text-base px-3",
51
+ },
52
+ color: {
53
+ gray: "bg-gray-100 text-gray-800",
54
+ blue: "bg-blue-100 text-blue-800",
55
+ },
56
+ });
57
+
58
+ badge({ size: "sm", color: "blue" }); // "badge text-xs px-1 bg-blue-100 text-blue-800"
59
+ ```
60
+
61
+ All variant keys are required unless covered by `.defaults()`.
62
+
63
+ ### Boolean shorthand
64
+
65
+ When a variant value is a plain string instead of an object, the prop becomes `boolean`. The string is applied when the prop is `true`; nothing is applied when it is `false` or `undefined`.
66
+
67
+ ```ts
68
+ import { styra } from "@ntatoud/styra";
69
+
70
+ const btn = styra("btn").variants({
71
+ disabled: "opacity-50 pointer-events-none",
72
+ active: "ring-2 ring-offset-2",
73
+ });
74
+
75
+ btn({ disabled: true, active: false }); // "btn opacity-50 pointer-events-none"
76
+ btn({ disabled: false, active: true }); // "btn ring-2 ring-offset-2"
77
+ btn({ disabled: false, active: false }); // "btn"
78
+ ```
79
+
80
+ Internally the shorthand `"opacity-50 pointer-events-none"` is stored as `{ true: "opacity-50 pointer-events-none", false: "" }`. The boolean prop value is coerced to the string `"true"` or `"false"` for map lookup.
81
+
82
+ ### Making variants optional with `.defaults()`
83
+
84
+ Call `.defaults()` with a partial map of variant keys to their default values. Variants covered by a default become optional in the inferred props type; uncovered variants remain required.
85
+
86
+ ```ts
87
+ import { styra, type VariantProps } from "@ntatoud/styra";
88
+
89
+ const chip = styra("chip")
90
+ .variants({
91
+ size: { sm: "text-xs", md: "text-sm", lg: "text-base" },
92
+ color: { blue: "bg-blue-100", green: "bg-green-100" },
93
+ })
94
+ .defaults({ size: "md" });
95
+
96
+ // size is optional (has default); color is required (no default)
97
+ type ChipProps = VariantProps<typeof chip>;
98
+ // → { size?: "sm" | "md" | "lg"; color: "blue" | "green" }
99
+
100
+ chip({ color: "blue" }); // "chip text-sm bg-blue-100"
101
+ chip({ size: "lg", color: "green" }); // "chip text-base bg-green-100"
102
+ ```
103
+
104
+ ### `class` and `className` ClassValue inputs
105
+
106
+ Both `class` and `className` props accept a `ClassValue`: a string, number, boolean, null, undefined, an array of `ClassValue`, or a `Record<string, unknown>` where truthy values include the key as a class. `className` also accepts a render-prop function returning `ClassValue`.
107
+
108
+ ```ts
109
+ import { styra, type ClassValue } from "@ntatoud/styra";
110
+
111
+ const btn = styra("btn").variants({ disabled: "opacity-50" });
112
+
113
+ // String
114
+ btn({ disabled: false, class: "mt-4" }); // "btn mt-4"
115
+
116
+ // Array
117
+ btn({ disabled: true, class: ["mt-4", "px-2"] }); // "btn opacity-50 mt-4 px-2"
118
+
119
+ // Object map — truthy values include the key
120
+ btn({ disabled: false, class: { "mt-4": true, "px-2": false } }); // "btn mt-4"
121
+
122
+ // Mixed array
123
+ btn({ disabled: false, class: ["mt-4", { "px-2": true, "py-1": false }] }); // "btn mt-4 px-2"
124
+
125
+ // className render-prop (Base UI / headless pattern)
126
+ btn({
127
+ disabled: true,
128
+ className: (state: { disabled: boolean }) => (state.disabled ? "cursor-not-allowed" : undefined),
129
+ });
130
+ ```
131
+
132
+ `VariantProps<T>` strips both `class` and `className` from the inferred type so component prop interfaces stay clean.
133
+
134
+ ## Common Mistakes
135
+
136
+ ### [CRITICAL] Omitting a required variant silently produces no class
137
+
138
+ Without a default, a missing variant resolves to `undefined`, which maps to no class. TypeScript catches this, but only when `VariantProps` is used for the component's prop type.
139
+
140
+ ```ts
141
+ // Wrong — size has no default and is not passed; TypeScript error is suppressed by `as any`
142
+ const card = styra("card").variants({ size: { sm: "p-2", lg: "p-6" } });
143
+ (card as any)({}); // "card" — size class is silently dropped
144
+ ```
145
+
146
+ ```ts
147
+ // Correct — either pass the variant or add a default
148
+ const card = styra("card")
149
+ .variants({ size: { sm: "p-2", lg: "p-6" } })
150
+ .defaults({ size: "sm" });
151
+
152
+ card({}); // "card p-2"
153
+ ```
154
+
155
+ Always use `VariantProps<typeof yourVariants>` as your component's prop type so TypeScript enforces required variants at call sites. Source: `packages/styra/src/types.ts` — `InferProps`.
156
+
157
+ ### [HIGH] Using `{ true: "...", false: "" }` when boolean shorthand is available
158
+
159
+ Explicit two-key objects are verbose and error-prone. Boolean shorthand produces the same runtime behavior with less code.
160
+
161
+ ```ts
162
+ // Wrong — verbose, no benefit over shorthand
163
+ const btn = styra("btn").variants({
164
+ disabled: { true: "opacity-50 pointer-events-none", false: "" },
165
+ });
166
+ ```
167
+
168
+ ```ts
169
+ // Correct — boolean shorthand
170
+ const btn = styra("btn").variants({
171
+ disabled: "opacity-50 pointer-events-none",
172
+ });
173
+ ```
174
+
175
+ The implementation converts the plain string to `{ true: raw, false: "" }` internally. Source: `packages/styra/src/index.ts` — `makeBuilder`.
176
+
177
+ ### [HIGH] Passing a boolean key in a compound rule when the stored key is the string `"true"`
178
+
179
+ Boolean shorthand stores the value under the string key `"true"`, not the JavaScript boolean `true`. Compound rules that reference boolean variants must use the string `"true"`.
180
+
181
+ ```ts
182
+ // Wrong — boolean true is not a valid key in a compound rule
183
+ const btn = styra("btn")
184
+ .variants({ disabled: "opacity-50", color: { red: "bg-red-500", blue: "bg-blue-500" } })
185
+ .compounds([{ disabled: true, color: "red", class: "border-red-700" }]); // runtime miss
186
+ ```
187
+
188
+ ```ts
189
+ // Correct — use the string "true" as the key value
190
+ const btn = styra("btn")
191
+ .variants({ disabled: "opacity-50", color: { red: "bg-red-500", blue: "bg-blue-500" } })
192
+ .compounds([{ disabled: "true", color: "red", class: "border-red-700" }]);
193
+ ```
194
+
195
+ The `toKey` coercion applies only to incoming prop values, not to the keys written in compound rule objects. Source: `packages/styra/src/index.ts` — `toKey`.
196
+
197
+ ### [MEDIUM] Expecting `VariantProps` to include `class` or `className`
198
+
199
+ `VariantProps<T>` deliberately omits `class` and `className`. Use the full `Parameters<T>[0]` type if you need to forward those props, or spread them separately.
200
+
201
+ ```ts
202
+ // Wrong — class is stripped from VariantProps
203
+ import { type VariantProps } from "@ntatoud/styra";
204
+
205
+ const btn = styra("btn").variants({ size: { sm: "text-sm", md: "text-md" } });
206
+ type BtnProps = VariantProps<typeof btn> & { onClick: () => void };
207
+ // BtnProps has no `class` or `className` — passing class from the parent is a TypeScript error
208
+ ```
209
+
210
+ ```ts
211
+ // Correct — add ClassValue explicitly when the component needs to accept extra classes
212
+ import { styra, type VariantProps, type ClassValue } from "@ntatoud/styra";
213
+
214
+ const btn = styra("btn").variants({ size: { sm: "text-sm", md: "text-md" } });
215
+ type BtnProps = VariantProps<typeof btn> & { class?: ClassValue; onClick: () => void };
216
+ ```
217
+
218
+ Source: `packages/styra/src/types.ts` — `VariantProps`.