@c9up/aurora 0.1.12 → 0.1.14

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/src/cn.ts ADDED
@@ -0,0 +1,979 @@
1
+ /**
2
+ * `cn` — class-name composition with Tailwind conflict resolution, reimplemented
3
+ * from scratch with ZERO dependencies (no clsx, no tailwind-merge).
4
+ *
5
+ * cn('px-2 py-1', isActive && 'bg-indigo-600', props.class)
6
+ * cn('px-2', 'px-4') // → 'px-4' (later wins within a group)
7
+ * cn('mr-4', 'mr-8') // → 'mr-8' (dedup is the whole point)
8
+ * cn('text-red-500', 'text-sm') // → 'text-red-500 text-sm' (different groups)
9
+ * cn('hover:p-2', 'hover:p-4', 'p-1')// → 'p-1 hover:p-4' (variant-scoped)
10
+ *
11
+ * Two parts:
12
+ * 1. {@link clsx} — flatten strings / numbers / arrays / objects into a class
13
+ * string, dropping falsy values (full clsx semantics).
14
+ * 2. {@link twMerge} — within the SAME variant stack (`hover:`, `md:`, `!`, …),
15
+ * keep only the LAST class of each conflicting Tailwind group; unknown
16
+ * classes never conflict and are always kept, in source order.
17
+ *
18
+ * Targets the standard Tailwind v4 utility set. Node-free — part of aurora's
19
+ * client runtime.
20
+ */
21
+
22
+ // ─── clsx ───────────────────────────────────────────────────────────────────
23
+
24
+ export type ClassValue =
25
+ | ClassValue[]
26
+ | Record<string, unknown>
27
+ | string
28
+ | number
29
+ | bigint
30
+ | null
31
+ | boolean
32
+ | undefined;
33
+
34
+ function appendValue(mix: ClassValue): string {
35
+ if (typeof mix === "string") return mix;
36
+ if (typeof mix === "number" || typeof mix === "bigint") {
37
+ // clsx includes any truthy number (0 / 0n are falsy → dropped).
38
+ return mix ? String(mix) : "";
39
+ }
40
+ if (typeof mix !== "object" || mix === null) return "";
41
+ if (Array.isArray(mix)) {
42
+ let out = "";
43
+ for (const item of mix) {
44
+ const piece = appendValue(item);
45
+ if (piece !== "") out = out === "" ? piece : `${out} ${piece}`;
46
+ }
47
+ return out;
48
+ }
49
+ // Plain object: include each key whose value is truthy.
50
+ let out = "";
51
+ for (const key in mix) {
52
+ if (mix[key]) out = out === "" ? key : `${out} ${key}`;
53
+ }
54
+ return out;
55
+ }
56
+
57
+ /** clsx-equivalent: join truthy class values (strings, numbers, arrays, objects). */
58
+ export function clsx(...inputs: ClassValue[]): string {
59
+ let out = "";
60
+ for (const input of inputs) {
61
+ const piece = appendValue(input);
62
+ if (piece !== "") out = out === "" ? piece : `${out} ${piece}`;
63
+ }
64
+ return out;
65
+ }
66
+
67
+ // ─── validators ──────────────────────────────────────────────────────────────
68
+
69
+ const FRACTION = /^\d+\/\d+$/;
70
+ const NUMBER = /^\d+(\.\d+)?$/;
71
+ const LENGTH_UNIT =
72
+ /^-?\d+(\.\d+)?(px|r?em|%|vh|vw|vmin|vmax|cm|mm|in|pt|pc|ex|ch|fr|deg|rad|grad|turn|s|ms|q)$/;
73
+ const TSHIRT = /^(\d+xs|xs|sm|md|lg|xl|\d+xl)$/;
74
+
75
+ const isArbitrary = (s: string): boolean => /^\[.+\]$/.test(s);
76
+ /** Arbitrary value tagged as a length/size, e.g. `[12px]`, `[length:…]`, `[3.2rem]`. */
77
+ const isArbitraryLength = (s: string): boolean =>
78
+ /^\[(length|size|percentage):/.test(s) ||
79
+ /^\[-?\d+(\.\d+)?(px|r?em|%|vh|vw|vmin|vmax|cm|mm|in|pt|pc|ex|ch|fr)\]$/.test(
80
+ s,
81
+ );
82
+ const isNumber = (s: string): boolean => NUMBER.test(s);
83
+ const isInteger = (s: string): boolean => /^\d+$/.test(s);
84
+ const isLength = (s: string): boolean =>
85
+ s === "px" ||
86
+ s === "full" ||
87
+ s === "auto" ||
88
+ NUMBER.test(s) ||
89
+ FRACTION.test(s) ||
90
+ LENGTH_UNIT.test(s) ||
91
+ isArbitrary(s);
92
+ const isTshirt = (s: string): boolean => TSHIRT.test(s);
93
+ const any = (): boolean => true;
94
+
95
+ const WEIGHTS = [
96
+ "thin",
97
+ "extralight",
98
+ "light",
99
+ "normal",
100
+ "medium",
101
+ "semibold",
102
+ "bold",
103
+ "extrabold",
104
+ "black",
105
+ ];
106
+
107
+ // ─── class-group registry (Tailwind v4 standard utilities) ───────────────────
108
+
109
+ type Validator = (rest: string) => boolean;
110
+
111
+ interface GroupRule {
112
+ /** Conflict-group id. Classes sharing an id (same variant stack) override. */
113
+ id: string;
114
+ /** Exact base class names that belong to this group. */
115
+ eq?: string[];
116
+ /** `[prefix, validator]` — base is `<prefix>-<rest>` and `validator(rest)` is true. */
117
+ pre?: Array<[string, Validator]>;
118
+ }
119
+
120
+ /** The eight Tailwind spacing/side suffixes (mx, my, ms, me, mt, mr, mb, ml…). */
121
+ const SIDES = ["x", "y", "s", "e", "t", "r", "b", "l"] as const;
122
+
123
+ /** A spacing-style family (p, m, scroll-m, scroll-p): a base group + 8 side groups. */
124
+ function spacing(prefix: string, validate: Validator): GroupRule[] {
125
+ return [
126
+ { id: prefix, pre: [[prefix, validate]] },
127
+ ...SIDES.map(
128
+ (s): GroupRule => ({
129
+ id: `${prefix}${s}`,
130
+ pre: [[`${prefix}${s}`, validate]],
131
+ }),
132
+ ),
133
+ ];
134
+ }
135
+
136
+ /** Conflicts for a spacing family: base overrides all sides; x→l,r and y→t,b. */
137
+ function spacingConflicts(prefix: string): Record<string, string[]> {
138
+ return {
139
+ [prefix]: SIDES.map((s) => `${prefix}${s}`),
140
+ [`${prefix}x`]: [`${prefix}l`, `${prefix}r`],
141
+ [`${prefix}y`]: [`${prefix}t`, `${prefix}b`],
142
+ };
143
+ }
144
+
145
+ /**
146
+ * ORDERED rules — first match wins, so the more specific keyword/size rules
147
+ * (text-shadow, border-collapse, font-stretch…) MUST precede the catch-all
148
+ * color rules (text-*, border-*, bg-*) that would otherwise swallow them.
149
+ */
150
+ const RULES: GroupRule[] = [
151
+ // ─ layout ─
152
+ { id: "aspect", pre: [["aspect", any]] },
153
+ { id: "container", eq: ["container"] },
154
+ { id: "columns", pre: [["columns", any]] },
155
+ { id: "break-after", pre: [["break-after", any]] },
156
+ { id: "break-before", pre: [["break-before", any]] },
157
+ { id: "break-inside", pre: [["break-inside", any]] },
158
+ {
159
+ id: "box-decoration",
160
+ eq: ["box-decoration-clone", "box-decoration-slice"],
161
+ },
162
+ { id: "box", eq: ["box-border", "box-content"] },
163
+ { id: "sr", eq: ["sr-only", "not-sr-only"] },
164
+ {
165
+ id: "display",
166
+ eq: [
167
+ "block",
168
+ "inline-block",
169
+ "inline",
170
+ "flex",
171
+ "inline-flex",
172
+ "table",
173
+ "inline-table",
174
+ "table-caption",
175
+ "table-cell",
176
+ "table-row",
177
+ "table-column",
178
+ "table-column-group",
179
+ "table-footer-group",
180
+ "table-header-group",
181
+ "table-row-group",
182
+ "flow-root",
183
+ "grid",
184
+ "inline-grid",
185
+ "contents",
186
+ "list-item",
187
+ "hidden",
188
+ ],
189
+ },
190
+ {
191
+ id: "float",
192
+ pre: [
193
+ ["float", (r) => ["right", "left", "none", "start", "end"].includes(r)],
194
+ ],
195
+ },
196
+ { id: "clear", pre: [["clear", any]] },
197
+ { id: "isolation", eq: ["isolate", "isolation-auto"] },
198
+ {
199
+ id: "object-fit",
200
+ pre: [
201
+ [
202
+ "object",
203
+ (r) => ["contain", "cover", "fill", "none", "scale-down"].includes(r),
204
+ ],
205
+ ],
206
+ },
207
+ { id: "object-position", pre: [["object", any]] },
208
+ { id: "overflow-x", pre: [["overflow-x", any]] },
209
+ { id: "overflow-y", pre: [["overflow-y", any]] },
210
+ {
211
+ id: "overflow",
212
+ pre: [["overflow", (r) => !r.startsWith("x-") && !r.startsWith("y-")]],
213
+ },
214
+ { id: "overscroll-x", pre: [["overscroll-x", any]] },
215
+ { id: "overscroll-y", pre: [["overscroll-y", any]] },
216
+ {
217
+ id: "overscroll",
218
+ pre: [["overscroll", (r) => !r.startsWith("x-") && !r.startsWith("y-")]],
219
+ },
220
+ { id: "position", eq: ["static", "fixed", "absolute", "relative", "sticky"] },
221
+ { id: "inset-x", pre: [["inset-x", any]] },
222
+ { id: "inset-y", pre: [["inset-y", any]] },
223
+ {
224
+ id: "inset",
225
+ pre: [
226
+ [
227
+ "inset",
228
+ (r) =>
229
+ r !== "ring" &&
230
+ r !== "shadow" &&
231
+ !["x-", "y-", "ring-", "shadow-"].some((p) => r.startsWith(p)),
232
+ ],
233
+ ],
234
+ },
235
+ { id: "top", pre: [["top", any]] },
236
+ { id: "right", pre: [["right", any]] },
237
+ { id: "bottom", pre: [["bottom", any]] },
238
+ { id: "left", pre: [["left", any]] },
239
+ { id: "start", pre: [["start", any]] },
240
+ { id: "end", pre: [["end", any]] },
241
+ { id: "visibility", eq: ["visible", "invisible", "collapse"] },
242
+ { id: "z", pre: [["z", any]] },
243
+
244
+ // ─ flexbox / grid ─
245
+ { id: "basis", pre: [["basis", any]] },
246
+ {
247
+ id: "flex-direction",
248
+ pre: [
249
+ ["flex", (r) => ["row", "row-reverse", "col", "col-reverse"].includes(r)],
250
+ ],
251
+ },
252
+ {
253
+ id: "flex-wrap",
254
+ pre: [["flex", (r) => ["wrap", "wrap-reverse", "nowrap"].includes(r)]],
255
+ },
256
+ {
257
+ id: "flex",
258
+ pre: [
259
+ [
260
+ "flex",
261
+ (r) =>
262
+ r === "1" ||
263
+ r === "auto" ||
264
+ r === "initial" ||
265
+ r === "none" ||
266
+ isNumber(r) ||
267
+ isFractionOrArbitrary(r),
268
+ ],
269
+ ],
270
+ },
271
+ { id: "grow", eq: ["grow"], pre: [["grow", isNumber]] },
272
+ { id: "shrink", eq: ["shrink"], pre: [["shrink", isNumber]] },
273
+ { id: "order", pre: [["order", any]] },
274
+ { id: "grid-cols", pre: [["grid-cols", any]] },
275
+ { id: "grid-rows", pre: [["grid-rows", any]] },
276
+ { id: "col-start-end", pre: [["col", any]] },
277
+ { id: "row-start-end", pre: [["row", any]] },
278
+ { id: "grid-flow", pre: [["grid-flow", any]] },
279
+ { id: "auto-cols", pre: [["auto-cols", any]] },
280
+ { id: "auto-rows", pre: [["auto-rows", any]] },
281
+ { id: "gap-x", pre: [["gap-x", isLength]] },
282
+ { id: "gap-y", pre: [["gap-y", isLength]] },
283
+ { id: "gap", pre: [["gap", isLength]] },
284
+ { id: "justify-items", pre: [["justify-items", any]] },
285
+ { id: "justify-self", pre: [["justify-self", any]] },
286
+ { id: "justify-content", pre: [["justify", any]] },
287
+ { id: "content", pre: [["content", (r) => r === "none" || isArbitrary(r)]] },
288
+ { id: "align-content", pre: [["content", any]] },
289
+ { id: "align-items", pre: [["items", any]] },
290
+ { id: "align-self", pre: [["self", any]] },
291
+ { id: "place-content", pre: [["place-content", any]] },
292
+ { id: "place-items", pre: [["place-items", any]] },
293
+ { id: "place-self", pre: [["place-self", any]] },
294
+
295
+ // ─ spacing ─
296
+ ...spacing("p", isLength),
297
+ ...spacing("m", isLength),
298
+ { id: "space-x-reverse", eq: ["space-x-reverse"] },
299
+ { id: "space-y-reverse", eq: ["space-y-reverse"] },
300
+ { id: "space-x", pre: [["space-x", any]] },
301
+ { id: "space-y", pre: [["space-y", any]] },
302
+ ...spacing("scroll-m", isLength),
303
+ ...spacing("scroll-p", isLength),
304
+
305
+ // ─ sizing ─
306
+ { id: "size", pre: [["size", isLength]] },
307
+ {
308
+ id: "w",
309
+ eq: ["w-screen", "w-min", "w-max", "w-fit"],
310
+ pre: [["w", isLength]],
311
+ },
312
+ { id: "min-w", pre: [["min-w", any]] },
313
+ { id: "max-w", pre: [["max-w", any]] },
314
+ {
315
+ id: "h",
316
+ eq: ["h-screen", "h-min", "h-max", "h-fit"],
317
+ pre: [["h", isLength]],
318
+ },
319
+ { id: "min-h", pre: [["min-h", any]] },
320
+ { id: "max-h", pre: [["max-h", any]] },
321
+
322
+ // ─ typography ─
323
+ { id: "font-stretch", pre: [["font-stretch", any]] },
324
+ {
325
+ id: "font-weight",
326
+ pre: [["font", (r) => WEIGHTS.includes(r) || isNumber(r)]],
327
+ },
328
+ { id: "font-family", pre: [["font", any]] },
329
+ {
330
+ id: "text-shadow",
331
+ eq: ["text-shadow"],
332
+ pre: [
333
+ ["text-shadow", (r) => isTshirt(r) || r === "none" || isArbitrary(r)],
334
+ ],
335
+ },
336
+ { id: "text-shadow-color", pre: [["text-shadow", any]] },
337
+ {
338
+ id: "text-align",
339
+ pre: [
340
+ [
341
+ "text",
342
+ (r) =>
343
+ ["left", "center", "right", "justify", "start", "end"].includes(r),
344
+ ],
345
+ ],
346
+ },
347
+ {
348
+ id: "text-overflow",
349
+ eq: ["truncate"],
350
+ pre: [["text", (r) => ["ellipsis", "clip"].includes(r)]],
351
+ },
352
+ {
353
+ id: "text-wrap",
354
+ pre: [["text", (r) => ["wrap", "nowrap", "balance", "pretty"].includes(r)]],
355
+ },
356
+ {
357
+ id: "font-size",
358
+ pre: [["text", (r) => isTshirt(r) || isLength(r) || isArbitraryLength(r)]],
359
+ },
360
+ { id: "text-color", pre: [["text", any]] },
361
+ { id: "leading", pre: [["leading", any]] },
362
+ { id: "tracking", pre: [["tracking", any]] },
363
+ { id: "line-clamp", pre: [["line-clamp", any]] },
364
+ {
365
+ id: "list-style-position",
366
+ pre: [["list", (r) => ["inside", "outside"].includes(r)]],
367
+ },
368
+ { id: "list-image", pre: [["list-image", any]] },
369
+ { id: "list-style-type", pre: [["list", any]] },
370
+ {
371
+ id: "text-decoration",
372
+ eq: ["underline", "overline", "line-through", "no-underline"],
373
+ },
374
+ {
375
+ id: "text-decoration-style",
376
+ pre: [
377
+ [
378
+ "decoration",
379
+ (r) => ["solid", "dashed", "dotted", "double", "wavy"].includes(r),
380
+ ],
381
+ ],
382
+ },
383
+ {
384
+ id: "text-decoration-thickness",
385
+ pre: [
386
+ ["decoration", (r) => r === "auto" || r === "from-font" || isLength(r)],
387
+ ],
388
+ },
389
+ { id: "decoration-color", pre: [["decoration", any]] },
390
+ { id: "underline-offset", pre: [["underline-offset", any]] },
391
+ {
392
+ id: "text-transform",
393
+ eq: ["uppercase", "lowercase", "capitalize", "normal-case"],
394
+ },
395
+ { id: "font-style", eq: ["italic", "not-italic"] },
396
+ { id: "font-smoothing", eq: ["antialiased", "subpixel-antialiased"] },
397
+ { id: "whitespace", pre: [["whitespace", any]] },
398
+ {
399
+ id: "word-break",
400
+ pre: [["break", (r) => ["normal", "words", "all", "keep"].includes(r)]],
401
+ },
402
+ { id: "hyphens", pre: [["hyphens", any]] },
403
+ { id: "indent", pre: [["indent", any]] },
404
+ { id: "align", pre: [["align", any]] },
405
+
406
+ // ─ backgrounds ─
407
+ {
408
+ id: "bg-attachment",
409
+ pre: [["bg", (r) => ["fixed", "local", "scroll"].includes(r)]],
410
+ },
411
+ { id: "bg-clip", pre: [["bg-clip", any]] },
412
+ { id: "bg-origin", pre: [["bg-origin", any]] },
413
+ {
414
+ id: "bg-position",
415
+ pre: [
416
+ [
417
+ "bg",
418
+ (r) =>
419
+ [
420
+ "bottom",
421
+ "center",
422
+ "left",
423
+ "left-bottom",
424
+ "left-top",
425
+ "right",
426
+ "right-bottom",
427
+ "right-top",
428
+ "top",
429
+ ].includes(r),
430
+ ],
431
+ ],
432
+ },
433
+ {
434
+ id: "bg-repeat",
435
+ pre: [
436
+ [
437
+ "bg",
438
+ (r) => r === "repeat" || r.startsWith("repeat-") || r === "no-repeat",
439
+ ],
440
+ ],
441
+ },
442
+ {
443
+ id: "bg-size",
444
+ pre: [["bg", (r) => ["auto", "cover", "contain"].includes(r)]],
445
+ },
446
+ {
447
+ id: "bg-image",
448
+ pre: [
449
+ [
450
+ "bg",
451
+ (r) =>
452
+ r === "none" ||
453
+ r.startsWith("gradient-") ||
454
+ r.startsWith("linear-") ||
455
+ r.startsWith("radial") ||
456
+ r.startsWith("conic"),
457
+ ],
458
+ ],
459
+ },
460
+ { id: "bg-blend", pre: [["bg-blend", any]] },
461
+ { id: "bg-color", pre: [["bg", any]] },
462
+ { id: "gradient-from", pre: [["from", any]] },
463
+ { id: "gradient-via", pre: [["via", any]] },
464
+ { id: "gradient-to", pre: [["to", any]] },
465
+
466
+ // ─ borders ─
467
+ { id: "rounded-ss", pre: [["rounded-ss", any]] },
468
+ { id: "rounded-se", pre: [["rounded-se", any]] },
469
+ { id: "rounded-ee", pre: [["rounded-ee", any]] },
470
+ { id: "rounded-es", pre: [["rounded-es", any]] },
471
+ { id: "rounded-s", pre: [["rounded-s", any]] },
472
+ { id: "rounded-e", pre: [["rounded-e", any]] },
473
+ { id: "rounded-t", pre: [["rounded-t", any]] },
474
+ { id: "rounded-r", pre: [["rounded-r", any]] },
475
+ { id: "rounded-b", pre: [["rounded-b", any]] },
476
+ { id: "rounded-l", pre: [["rounded-l", any]] },
477
+ { id: "rounded-tl", pre: [["rounded-tl", any]] },
478
+ { id: "rounded-tr", pre: [["rounded-tr", any]] },
479
+ { id: "rounded-br", pre: [["rounded-br", any]] },
480
+ { id: "rounded-bl", pre: [["rounded-bl", any]] },
481
+ {
482
+ id: "rounded",
483
+ eq: ["rounded"],
484
+ pre: [
485
+ [
486
+ "rounded",
487
+ (r) => isTshirt(r) || r === "none" || r === "full" || isArbitrary(r),
488
+ ],
489
+ ],
490
+ },
491
+ { id: "border-collapse", eq: ["border-collapse", "border-separate"] },
492
+ { id: "border-spacing-x", pre: [["border-spacing-x", any]] },
493
+ { id: "border-spacing-y", pre: [["border-spacing-y", any]] },
494
+ {
495
+ id: "border-spacing",
496
+ pre: [
497
+ ["border-spacing", (r) => !r.startsWith("x-") && !r.startsWith("y-")],
498
+ ],
499
+ },
500
+ {
501
+ id: "border-style",
502
+ pre: [
503
+ [
504
+ "border",
505
+ (r) =>
506
+ ["solid", "dashed", "dotted", "double", "hidden", "none"].includes(r),
507
+ ],
508
+ ],
509
+ },
510
+ {
511
+ id: "border-w-x",
512
+ pre: [
513
+ [
514
+ "border-x",
515
+ (r) => r === "" || isInteger(r) || r === "px" || isArbitrary(r),
516
+ ],
517
+ ],
518
+ },
519
+ {
520
+ id: "border-w-y",
521
+ pre: [
522
+ [
523
+ "border-y",
524
+ (r) => r === "" || isInteger(r) || r === "px" || isArbitrary(r),
525
+ ],
526
+ ],
527
+ },
528
+ {
529
+ id: "border-w-t",
530
+ pre: [
531
+ [
532
+ "border-t",
533
+ (r) => r === "" || isInteger(r) || r === "px" || isArbitrary(r),
534
+ ],
535
+ ],
536
+ },
537
+ {
538
+ id: "border-w-r",
539
+ pre: [
540
+ [
541
+ "border-r",
542
+ (r) => r === "" || isInteger(r) || r === "px" || isArbitrary(r),
543
+ ],
544
+ ],
545
+ },
546
+ {
547
+ id: "border-w-b",
548
+ pre: [
549
+ [
550
+ "border-b",
551
+ (r) => r === "" || isInteger(r) || r === "px" || isArbitrary(r),
552
+ ],
553
+ ],
554
+ },
555
+ {
556
+ id: "border-w-l",
557
+ pre: [
558
+ [
559
+ "border-l",
560
+ (r) => r === "" || isInteger(r) || r === "px" || isArbitrary(r),
561
+ ],
562
+ ],
563
+ },
564
+ {
565
+ id: "border-w",
566
+ eq: ["border"],
567
+ pre: [["border", (r) => isInteger(r) || r === "px" || isArbitrary(r)]],
568
+ },
569
+ { id: "border-color-x", pre: [["border-x", any]] },
570
+ { id: "border-color-y", pre: [["border-y", any]] },
571
+ { id: "border-color-t", pre: [["border-t", any]] },
572
+ { id: "border-color-r", pre: [["border-r", any]] },
573
+ { id: "border-color-b", pre: [["border-b", any]] },
574
+ { id: "border-color-l", pre: [["border-l", any]] },
575
+ { id: "border-color", pre: [["border", any]] },
576
+ { id: "divide-x-reverse", eq: ["divide-x-reverse"] },
577
+ { id: "divide-y-reverse", eq: ["divide-y-reverse"] },
578
+ { id: "divide-x", pre: [["divide-x", any]] },
579
+ { id: "divide-y", pre: [["divide-y", any]] },
580
+ {
581
+ id: "divide-style",
582
+ pre: [
583
+ [
584
+ "divide",
585
+ (r) => ["solid", "dashed", "dotted", "double", "none"].includes(r),
586
+ ],
587
+ ],
588
+ },
589
+ { id: "divide-color", pre: [["divide", any]] },
590
+ {
591
+ id: "outline-style",
592
+ eq: ["outline", "outline-none"],
593
+ pre: [["outline", (r) => ["dashed", "dotted", "double"].includes(r)]],
594
+ },
595
+ { id: "outline-offset", pre: [["outline-offset", any]] },
596
+ {
597
+ id: "outline-w",
598
+ pre: [["outline", (r) => isInteger(r) || isArbitrary(r)]],
599
+ },
600
+ { id: "outline-color", pre: [["outline", any]] },
601
+ {
602
+ id: "inset-ring-w",
603
+ eq: ["inset-ring"],
604
+ pre: [["inset-ring", (r) => isInteger(r) || isArbitrary(r)]],
605
+ },
606
+ { id: "inset-ring-color", pre: [["inset-ring", any]] },
607
+ {
608
+ id: "ring-w",
609
+ eq: ["ring"],
610
+ pre: [["ring", (r) => isInteger(r) || r === "inset" || isArbitrary(r)]],
611
+ },
612
+ {
613
+ id: "ring-offset-w",
614
+ pre: [["ring-offset", (r) => isInteger(r) || isArbitrary(r)]],
615
+ },
616
+ { id: "ring-offset-color", pre: [["ring-offset", any]] },
617
+ { id: "ring-color", pre: [["ring", any]] },
618
+
619
+ // ─ effects ─
620
+ {
621
+ id: "shadow",
622
+ eq: ["shadow"],
623
+ pre: [
624
+ [
625
+ "shadow",
626
+ (r) => isTshirt(r) || r === "none" || r === "inner" || isArbitrary(r),
627
+ ],
628
+ ],
629
+ },
630
+ { id: "shadow-color", pre: [["shadow", any]] },
631
+ {
632
+ id: "inset-shadow",
633
+ eq: ["inset-shadow"],
634
+ pre: [
635
+ ["inset-shadow", (r) => isTshirt(r) || r === "none" || isArbitrary(r)],
636
+ ],
637
+ },
638
+ { id: "inset-shadow-color", pre: [["inset-shadow", any]] },
639
+ { id: "opacity", pre: [["opacity", any]] },
640
+ { id: "mix-blend", pre: [["mix-blend", any]] },
641
+
642
+ // ─ filters ─
643
+ { id: "filter", eq: ["filter", "filter-none"] },
644
+ { id: "blur", eq: ["blur"], pre: [["blur", any]] },
645
+ { id: "brightness", pre: [["brightness", any]] },
646
+ { id: "contrast", pre: [["contrast", any]] },
647
+ {
648
+ id: "drop-shadow",
649
+ eq: ["drop-shadow"],
650
+ pre: [
651
+ ["drop-shadow", (r) => isTshirt(r) || r === "none" || isArbitrary(r)],
652
+ ],
653
+ },
654
+ { id: "drop-shadow-color", pre: [["drop-shadow", any]] },
655
+ { id: "grayscale", eq: ["grayscale"], pre: [["grayscale", any]] },
656
+ { id: "hue-rotate", pre: [["hue-rotate", any]] },
657
+ { id: "invert", eq: ["invert"], pre: [["invert", any]] },
658
+ { id: "saturate", pre: [["saturate", any]] },
659
+ { id: "sepia", eq: ["sepia"], pre: [["sepia", any]] },
660
+ { id: "backdrop-filter", eq: ["backdrop-filter", "backdrop-filter-none"] },
661
+ { id: "backdrop-blur", eq: ["backdrop-blur"], pre: [["backdrop-blur", any]] },
662
+ { id: "backdrop-brightness", pre: [["backdrop-brightness", any]] },
663
+ { id: "backdrop-contrast", pre: [["backdrop-contrast", any]] },
664
+ {
665
+ id: "backdrop-grayscale",
666
+ eq: ["backdrop-grayscale"],
667
+ pre: [["backdrop-grayscale", any]],
668
+ },
669
+ { id: "backdrop-hue-rotate", pre: [["backdrop-hue-rotate", any]] },
670
+ {
671
+ id: "backdrop-invert",
672
+ eq: ["backdrop-invert"],
673
+ pre: [["backdrop-invert", any]],
674
+ },
675
+ { id: "backdrop-opacity", pre: [["backdrop-opacity", any]] },
676
+ { id: "backdrop-saturate", pre: [["backdrop-saturate", any]] },
677
+ {
678
+ id: "backdrop-sepia",
679
+ eq: ["backdrop-sepia"],
680
+ pre: [["backdrop-sepia", any]],
681
+ },
682
+
683
+ // ─ tables ─
684
+ {
685
+ id: "table-layout",
686
+ pre: [["table", (r) => ["auto", "fixed"].includes(r)]],
687
+ },
688
+ { id: "caption", pre: [["caption", any]] },
689
+
690
+ // ─ transitions / animation ─
691
+ { id: "transition", eq: ["transition"], pre: [["transition", any]] },
692
+ { id: "duration", pre: [["duration", any]] },
693
+ { id: "ease", pre: [["ease", any]] },
694
+ { id: "delay", pre: [["delay", any]] },
695
+ { id: "animate", pre: [["animate", any]] },
696
+
697
+ // ─ transforms ─
698
+ {
699
+ id: "transform",
700
+ eq: ["transform", "transform-none", "transform-gpu", "transform-cpu"],
701
+ },
702
+ { id: "transform-origin", pre: [["origin", any]] },
703
+ { id: "perspective-origin", pre: [["perspective-origin", any]] },
704
+ { id: "perspective", pre: [["perspective", any]] },
705
+ { id: "backface", pre: [["backface", any]] },
706
+ { id: "scale-x", pre: [["scale-x", any]] },
707
+ { id: "scale-y", pre: [["scale-y", any]] },
708
+ { id: "scale-z", pre: [["scale-z", any]] },
709
+ { id: "scale", pre: [["scale", any]] },
710
+ { id: "rotate-x", pre: [["rotate-x", any]] },
711
+ { id: "rotate-y", pre: [["rotate-y", any]] },
712
+ { id: "rotate-z", pre: [["rotate-z", any]] },
713
+ { id: "rotate", pre: [["rotate", any]] },
714
+ { id: "translate-x", pre: [["translate-x", any]] },
715
+ { id: "translate-y", pre: [["translate-y", any]] },
716
+ { id: "translate-z", pre: [["translate-z", any]] },
717
+ { id: "translate", pre: [["translate", any]] },
718
+ { id: "skew-x", pre: [["skew-x", any]] },
719
+ { id: "skew-y", pre: [["skew-y", any]] },
720
+ { id: "skew", pre: [["skew", any]] },
721
+
722
+ // ─ interactivity ─
723
+ { id: "accent", pre: [["accent", any]] },
724
+ { id: "appearance", pre: [["appearance", any]] },
725
+ { id: "caret", pre: [["caret", any]] },
726
+ { id: "color-scheme", pre: [["scheme", any]] },
727
+ { id: "cursor", pre: [["cursor", any]] },
728
+ { id: "field-sizing", pre: [["field-sizing", any]] },
729
+ { id: "pointer-events", pre: [["pointer-events", any]] },
730
+ { id: "resize", eq: ["resize", "resize-none", "resize-x", "resize-y"] },
731
+ {
732
+ id: "scroll-behavior",
733
+ pre: [["scroll", (r) => ["auto", "smooth"].includes(r)]],
734
+ },
735
+ {
736
+ id: "snap-align",
737
+ pre: [
738
+ ["snap", (r) => ["start", "end", "center", "align-none"].includes(r)],
739
+ ],
740
+ },
741
+ { id: "snap-stop", pre: [["snap", (r) => ["normal", "always"].includes(r)]] },
742
+ {
743
+ id: "snap-strictness",
744
+ pre: [["snap", (r) => ["mandatory", "proximity"].includes(r)]],
745
+ },
746
+ {
747
+ id: "snap-type",
748
+ pre: [["snap", (r) => ["none", "x", "y", "both"].includes(r)]],
749
+ },
750
+ { id: "select", pre: [["select", any]] },
751
+ { id: "touch", pre: [["touch", any]] },
752
+ { id: "user-select", pre: [["user-select", any]] },
753
+ { id: "will-change", pre: [["will-change", any]] },
754
+
755
+ // ─ svg ─
756
+ { id: "fill", pre: [["fill", any]] },
757
+ {
758
+ id: "stroke-w",
759
+ pre: [["stroke", (r) => isInteger(r) || isArbitraryLength(r)]],
760
+ },
761
+ { id: "stroke", pre: [["stroke", any]] },
762
+
763
+ // ─ accessibility ─
764
+ { id: "forced-color-adjust", pre: [["forced-color-adjust", any]] },
765
+
766
+ // ─ masking (Tailwind v4) ─
767
+ { id: "mask-type", pre: [["mask-type", any]] },
768
+ { id: "mask-clip", pre: [["mask-clip", any]] },
769
+ { id: "mask-origin", pre: [["mask-origin", any]] },
770
+ {
771
+ id: "mask-mode",
772
+ pre: [["mask", (r) => ["alpha", "luminance", "match"].includes(r)]],
773
+ },
774
+ {
775
+ id: "mask-composite",
776
+ pre: [
777
+ ["mask", (r) => ["add", "subtract", "intersect", "exclude"].includes(r)],
778
+ ],
779
+ },
780
+ {
781
+ id: "mask-repeat",
782
+ pre: [
783
+ [
784
+ "mask",
785
+ (r) => r === "repeat" || r.startsWith("repeat-") || r === "no-repeat",
786
+ ],
787
+ ],
788
+ },
789
+ {
790
+ id: "mask-size",
791
+ pre: [["mask", (r) => ["auto", "cover", "contain"].includes(r)]],
792
+ },
793
+ { id: "mask-image", pre: [["mask", (r) => r === "none" || isArbitrary(r)]] },
794
+ ];
795
+
796
+ /** flex shorthand can take a fraction (`flex-1/2`) or an arbitrary value. */
797
+ function isFractionOrArbitrary(s: string): boolean {
798
+ return FRACTION.test(s) || isArbitrary(s);
799
+ }
800
+
801
+ /**
802
+ * Groups that a kept class additionally overrides — e.g. `p-4` overrides every
803
+ * `px/py/pt/…`, and `inset-0` overrides `top/left/…`. Keyed group → groups it
804
+ * supersedes. Resolution marks these as claimed so an EARLIER such class drops.
805
+ */
806
+ const CONFLICTS: Record<string, string[]> = {
807
+ overflow: ["overflow-x", "overflow-y"],
808
+ overscroll: ["overscroll-x", "overscroll-y"],
809
+ inset: [
810
+ "inset-x",
811
+ "inset-y",
812
+ "top",
813
+ "right",
814
+ "bottom",
815
+ "left",
816
+ "start",
817
+ "end",
818
+ ],
819
+ "inset-x": ["right", "left"],
820
+ "inset-y": ["top", "bottom"],
821
+ ...spacingConflicts("p"),
822
+ ...spacingConflicts("m"),
823
+ ...spacingConflicts("scroll-m"),
824
+ ...spacingConflicts("scroll-p"),
825
+ gap: ["gap-x", "gap-y"],
826
+ size: ["w", "h"],
827
+ rounded: [
828
+ "rounded-ss",
829
+ "rounded-se",
830
+ "rounded-ee",
831
+ "rounded-es",
832
+ "rounded-s",
833
+ "rounded-e",
834
+ "rounded-t",
835
+ "rounded-r",
836
+ "rounded-b",
837
+ "rounded-l",
838
+ "rounded-tl",
839
+ "rounded-tr",
840
+ "rounded-br",
841
+ "rounded-bl",
842
+ ],
843
+ "rounded-s": ["rounded-ss", "rounded-es"],
844
+ "rounded-e": ["rounded-se", "rounded-ee"],
845
+ "rounded-t": ["rounded-tl", "rounded-tr"],
846
+ "rounded-r": ["rounded-tr", "rounded-br"],
847
+ "rounded-b": ["rounded-br", "rounded-bl"],
848
+ "rounded-l": ["rounded-tl", "rounded-bl"],
849
+ "border-w": [
850
+ "border-w-x",
851
+ "border-w-y",
852
+ "border-w-t",
853
+ "border-w-r",
854
+ "border-w-b",
855
+ "border-w-l",
856
+ ],
857
+ "border-w-x": ["border-w-l", "border-w-r"],
858
+ "border-w-y": ["border-w-t", "border-w-b"],
859
+ "border-color": [
860
+ "border-color-x",
861
+ "border-color-y",
862
+ "border-color-t",
863
+ "border-color-r",
864
+ "border-color-b",
865
+ "border-color-l",
866
+ ],
867
+ "border-color-x": ["border-color-l", "border-color-r"],
868
+ "border-color-y": ["border-color-t", "border-color-b"],
869
+ "border-spacing": ["border-spacing-x", "border-spacing-y"],
870
+ scale: ["scale-x", "scale-y", "scale-z"],
871
+ translate: ["translate-x", "translate-y", "translate-z"],
872
+ skew: ["skew-x", "skew-y"],
873
+ };
874
+
875
+ // ─── parser ──────────────────────────────────────────────────────────────────
876
+
877
+ interface Parsed {
878
+ className: string;
879
+ /** Sorted variant stack + important flag — the conflict SCOPE key. */
880
+ scope: string;
881
+ /** Conflict-group id, or null for unknown classes (never conflict). */
882
+ groupId: string | null;
883
+ }
884
+
885
+ /** Split on a separator char at BRACKET DEPTH 0 (so `[&:hover]` / `[x:y]` stay intact). */
886
+ function splitTopLevel(s: string, sep: string): string[] {
887
+ const parts: string[] = [];
888
+ let depth = 0;
889
+ let buf = "";
890
+ for (const ch of s) {
891
+ if (ch === "[") depth++;
892
+ else if (ch === "]") depth = depth > 0 ? depth - 1 : 0;
893
+ if (ch === sep && depth === 0) {
894
+ parts.push(buf);
895
+ buf = "";
896
+ } else {
897
+ buf += ch;
898
+ }
899
+ }
900
+ parts.push(buf);
901
+ return parts;
902
+ }
903
+
904
+ function resolveGroup(base: string): string | null {
905
+ // Arbitrary property `[mask-type:luminance]` → its own per-property group.
906
+ if (base.startsWith("[") && base.endsWith("]")) {
907
+ const colon = base.indexOf(":");
908
+ return colon > 0 ? `arbitrary:${base.slice(1, colon)}` : "arbitrary";
909
+ }
910
+ for (const rule of RULES) {
911
+ if (rule.eq?.includes(base)) return rule.id;
912
+ if (rule.pre) {
913
+ for (const [prefix, validate] of rule.pre) {
914
+ if (base === prefix && validate("")) return rule.id;
915
+ if (
916
+ base.startsWith(`${prefix}-`) &&
917
+ validate(base.slice(prefix.length + 1))
918
+ ) {
919
+ return rule.id;
920
+ }
921
+ }
922
+ }
923
+ }
924
+ return null;
925
+ }
926
+
927
+ function parse(className: string): Parsed {
928
+ const modifiers = splitTopLevel(className, ":");
929
+ const last = modifiers.pop() ?? "";
930
+ let base = last;
931
+ let important = false;
932
+ // Important modifier — Tailwind v4 trailing `!` (e.g. `bg-red-500!`).
933
+ if (base.endsWith("!")) {
934
+ important = true;
935
+ base = base.slice(0, -1);
936
+ }
937
+ // Drop the opacity/postfix modifier (`/50`) at top level — not part of the group.
938
+ base = splitTopLevel(base, "/")[0] ?? base;
939
+ // Negative utilities share their positive group (`-mt-2` ≡ `mt-2`).
940
+ const lookup = base.startsWith("-") ? base.slice(1) : base;
941
+ const scope = `${modifiers.slice().sort().join(":")}${important ? "!" : ""}`;
942
+ return { className, scope, groupId: resolveGroup(lookup) };
943
+ }
944
+
945
+ // ─── twMerge ─────────────────────────────────────────────────────────────────
946
+
947
+ /** Resolve Tailwind class conflicts: within a variant scope, the last class of each group wins. */
948
+ export function twMerge(classList: string): string {
949
+ const classes = classList.split(/\s+/).filter(Boolean);
950
+ const claimed = new Set<string>();
951
+ const kept: string[] = [];
952
+ // Walk right→left: the first time we see a (scope, group) it's the winner;
953
+ // any earlier class of that group (or a group it supersedes) is dropped.
954
+ for (let i = classes.length - 1; i >= 0; i--) {
955
+ const cls = classes[i];
956
+ if (cls === undefined) continue;
957
+ const { scope, groupId } = parse(cls);
958
+ if (groupId === null) {
959
+ kept.push(cls); // unknown class — never conflicts
960
+ continue;
961
+ }
962
+ const key = `${scope}|${groupId}`;
963
+ if (claimed.has(key)) continue; // already overridden by a later class
964
+ kept.push(cls);
965
+ claimed.add(key);
966
+ const supersedes = CONFLICTS[groupId];
967
+ if (supersedes) {
968
+ for (const g of supersedes) claimed.add(`${scope}|${g}`);
969
+ }
970
+ }
971
+ return kept.reverse().join(" ");
972
+ }
973
+
974
+ // ─── cn ──────────────────────────────────────────────────────────────────────
975
+
976
+ /** Compose class values (clsx) then resolve Tailwind conflicts (twMerge). */
977
+ export function cn(...inputs: ClassValue[]): string {
978
+ return twMerge(clsx(...inputs));
979
+ }