@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 +8 -0
- package/dist/index.d.mts +10 -7
- package/dist/index.mjs +41 -7
- package/package.json +5 -2
- package/skills/styra-compound-variants/SKILL.md +305 -0
- package/skills/styra-define-variants/SKILL.md +218 -0
- package/skills/styra-getting-started/SKILL.md +252 -0
- package/skills/styra-migrate-from-cva/SKILL.md +314 -0
- package/skills/styra-shadcn-integration/SKILL.md +246 -0
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?:
|
|
30
|
-
className?:
|
|
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?:
|
|
36
|
-
className?:
|
|
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
|
-
/**
|
|
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 === "
|
|
16
|
-
|
|
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
|
-
|
|
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.
|
|
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`.
|