@aihu/css-engine 0.2.5 → 0.3.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.
@@ -22,7 +22,9 @@ fn category_colors() {
22
22
 
23
23
  #[test]
24
24
  fn category_spacing() {
25
- insta::assert_snapshot!(css(&["p-4", "px-2", "py-8", "m-0", "mt-1", "gap-2", "p-0.5"]));
25
+ insta::assert_snapshot!(css(&[
26
+ "p-4", "px-2", "py-8", "m-0", "mt-1", "gap-2", "p-0.5"
27
+ ]));
26
28
  }
27
29
 
28
30
  #[test]
@@ -54,12 +56,7 @@ fn category_typography() {
54
56
 
55
57
  #[test]
56
58
  fn category_borders() {
57
- insta::assert_snapshot!(css(&[
58
- "border",
59
- "rounded",
60
- "rounded-lg",
61
- "rounded-full",
62
- ]));
59
+ insta::assert_snapshot!(css(&["border", "rounded", "rounded-lg", "rounded-full",]));
63
60
  }
64
61
 
65
62
  #[test]
@@ -67,6 +64,17 @@ fn category_effects() {
67
64
  insta::assert_snapshot!(css(&["shadow", "shadow-lg", "shadow-none", "opacity-50"]));
68
65
  }
69
66
 
67
+ #[test]
68
+ fn relational_marker_classes() {
69
+ // `group` / `peer` are marker utilities: they emit an empty-body rule (no
70
+ // declarations) so the class survives into the sheet for `group-*:` /
71
+ // `peer-*:` relational selectors to target. (Variant emission itself is
72
+ // covered in tests/emit.rs, which uses the scoped pipeline.)
73
+ let out = css(&["group", "peer"]);
74
+ assert!(out.contains(".group { }"), "bare group marker: {out}");
75
+ assert!(out.contains(".peer { }"), "bare peer marker: {out}");
76
+ }
77
+
70
78
  #[test]
71
79
  fn arbitrary_values() {
72
80
  insta::assert_snapshot!(css(&[
@@ -77,3 +85,462 @@ fn arbitrary_values() {
77
85
  "leading-[1.4]",
78
86
  ]));
79
87
  }
88
+
89
+ // --- New utility families (Round 1: tailwind-support) ---------------------
90
+ //
91
+ // These use exact-string assertions (not snapshots) so the emitted CSS is
92
+ // pinned per-class. The expected declarations mirror Tailwind v4 defaults.
93
+
94
+ #[test]
95
+ fn space_x_emits_nested_sibling_margin() {
96
+ assert_eq!(
97
+ css(&["space-x-4"]),
98
+ ".space-x-4 { & > * + * { margin-inline-start: 1rem; } }\n"
99
+ );
100
+ }
101
+
102
+ #[test]
103
+ fn space_y_emits_nested_sibling_margin() {
104
+ assert_eq!(
105
+ css(&["space-y-2"]),
106
+ ".space-y-2 { & > * + * { margin-block-start: 0.5rem; } }\n"
107
+ );
108
+ }
109
+
110
+ #[test]
111
+ fn mx_auto_emits_margin_inline_auto() {
112
+ assert_eq!(css(&["mx-auto"]), ".mx-auto { margin-inline: auto; }\n");
113
+ }
114
+
115
+ #[test]
116
+ fn max_w_named_scale() {
117
+ assert_eq!(css(&["max-w-7xl"]), ".max-w-7xl { max-width: 80rem; }\n");
118
+ assert_eq!(css(&["max-w-prose"]), ".max-w-prose { max-width: 65ch; }\n");
119
+ }
120
+
121
+ #[test]
122
+ fn grid_cols_repeat() {
123
+ assert_eq!(
124
+ css(&["grid-cols-3"]),
125
+ ".grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }\n"
126
+ );
127
+ }
128
+
129
+ #[test]
130
+ fn col_span_n() {
131
+ assert_eq!(
132
+ css(&["col-span-2"]),
133
+ ".col-span-2 { grid-column: span 2 / span 2; }\n"
134
+ );
135
+ }
136
+
137
+ #[test]
138
+ fn row_span_full_keyword() {
139
+ assert_eq!(
140
+ css(&["row-span-full"]),
141
+ ".row-span-full { grid-row: 1 / -1; }\n"
142
+ );
143
+ }
144
+
145
+ #[test]
146
+ fn border_n_width() {
147
+ assert_eq!(css(&["border-2"]), ".border-2 { border-width: 2px; }\n");
148
+ }
149
+
150
+ #[test]
151
+ fn border_directional_n_width() {
152
+ assert_eq!(
153
+ css(&["border-t-4"]),
154
+ ".border-t-4 { border-top-width: 4px; }\n"
155
+ );
156
+ }
157
+
158
+ #[test]
159
+ fn z_auto_keyword() {
160
+ assert_eq!(css(&["z-auto"]), ".z-auto { z-index: auto; }\n");
161
+ }
162
+
163
+ // --- Round 2: divide-x / divide-y sibling borders -------------------------
164
+ //
165
+ // Reuses the proven `space-*` nested `& > * + *` recipe. Exact-string
166
+ // assertions pin every family: bare (1px default), numeric widths, reverse.
167
+
168
+ #[test]
169
+ fn divide_x_bare_defaults_to_1px() {
170
+ assert_eq!(
171
+ css(&["divide-x"]),
172
+ ".divide-x { & > * + * { border-inline-width: 1px; } }\n"
173
+ );
174
+ }
175
+
176
+ // --- New utility families (Round 2: tailwind-support — named scales) -------
177
+ //
178
+ // Position scale (top/right/bottom/left/inset/inset-x/inset-y) on the spacing
179
+ // scale + `auto` + negative forms; named leading-* / numeric leading-<n>;
180
+ // named tracking-*. Exact-string assertions pin the emitted declarations.
181
+
182
+ #[test]
183
+ fn position_top_scale() {
184
+ assert_eq!(css(&["top-4"]), ".top-4 { top: 1rem; }\n");
185
+ }
186
+
187
+ #[test]
188
+ fn position_right_scale() {
189
+ assert_eq!(css(&["right-4"]), ".right-4 { right: 1rem; }\n");
190
+ }
191
+
192
+ #[test]
193
+ fn position_bottom_left_scale() {
194
+ assert_eq!(css(&["bottom-2"]), ".bottom-2 { bottom: 0.5rem; }\n");
195
+ assert_eq!(css(&["left-8"]), ".left-8 { left: 2rem; }\n");
196
+ }
197
+
198
+ #[test]
199
+ fn position_inset_all_sides() {
200
+ assert_eq!(css(&["inset-0"]), ".inset-0 { inset: 0; }\n");
201
+ assert_eq!(css(&["inset-4"]), ".inset-4 { inset: 1rem; }\n");
202
+ }
203
+
204
+ #[test]
205
+ fn position_inset_logical_axes() {
206
+ assert_eq!(css(&["inset-x-2"]), ".inset-x-2 { inset-inline: 0.5rem; }\n");
207
+ assert_eq!(css(&["inset-y-4"]), ".inset-y-4 { inset-block: 1rem; }\n");
208
+ }
209
+
210
+ #[test]
211
+ fn position_auto() {
212
+ assert_eq!(css(&["top-auto"]), ".top-auto { top: auto; }\n");
213
+ }
214
+
215
+ #[test]
216
+ fn position_negative() {
217
+ assert_eq!(css(&["-left-2"]), ".-left-2 { left: -0.5rem; }\n");
218
+ assert_eq!(css(&["-top-4"]), ".-top-4 { top: -1rem; }\n");
219
+ assert_eq!(css(&["-inset-x-2"]), ".-inset-x-2 { inset-inline: -0.5rem; }\n");
220
+ }
221
+
222
+ #[test]
223
+ fn leading_named_scale() {
224
+ assert_eq!(css(&["leading-none"]), ".leading-none { line-height: 1; }\n");
225
+ assert_eq!(css(&["leading-tight"]), ".leading-tight { line-height: 1.25; }\n");
226
+ assert_eq!(css(&["leading-snug"]), ".leading-snug { line-height: 1.375; }\n");
227
+ assert_eq!(css(&["leading-normal"]), ".leading-normal { line-height: 1.5; }\n");
228
+ assert_eq!(
229
+ css(&["leading-relaxed"]),
230
+ ".leading-relaxed { line-height: 1.625; }\n"
231
+ );
232
+ assert_eq!(css(&["leading-loose"]), ".leading-loose { line-height: 2; }\n");
233
+ }
234
+
235
+ #[test]
236
+ fn leading_numeric_step() {
237
+ // Numeric leading-<n> maps to the spacing scale (Tailwind v4).
238
+ assert_eq!(css(&["leading-6"]), ".leading-6 { line-height: 1.5rem; }\n");
239
+ }
240
+
241
+ #[test]
242
+ fn tracking_named_scale() {
243
+ assert_eq!(
244
+ css(&["tracking-tighter"]),
245
+ ".tracking-tighter { letter-spacing: -0.05em; }\n"
246
+ );
247
+ assert_eq!(
248
+ css(&["tracking-tight"]),
249
+ ".tracking-tight { letter-spacing: -0.025em; }\n"
250
+ );
251
+ assert_eq!(
252
+ css(&["tracking-normal"]),
253
+ ".tracking-normal { letter-spacing: 0em; }\n"
254
+ );
255
+ assert_eq!(
256
+ css(&["tracking-wide"]),
257
+ ".tracking-wide { letter-spacing: 0.025em; }\n"
258
+ );
259
+ assert_eq!(
260
+ css(&["tracking-wider"]),
261
+ ".tracking-wider { letter-spacing: 0.05em; }\n"
262
+ );
263
+ assert_eq!(
264
+ css(&["tracking-widest"]),
265
+ ".tracking-widest { letter-spacing: 0.1em; }\n"
266
+ );
267
+ }
268
+
269
+ // --- New utility families (Round 2: ring widths + ring-offset) -------------
270
+ //
271
+ // `ring-{n}` emits the Tailwind v4 box-shadow ring composed from `--tw-ring-*`
272
+ // custom properties; `ring-offset-{n}` sets `--tw-ring-offset-width`;
273
+ // `ring-inset` flips the inset slot; bare `ring` is the 3px default. CRITICAL
274
+ // regression guard: `ring-<color>` must STILL route to `--tw-ring-color`.
275
+
276
+ #[test]
277
+ fn ring_n_emits_box_shadow_ring() {
278
+ assert_eq!(
279
+ css(&["ring-2"]),
280
+ ".ring-2 { --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 \
281
+ var(--tw-ring-offset-width) var(--tw-ring-offset-color); \
282
+ --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) \
283
+ var(--tw-ring-color); box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), \
284
+ var(--tw-shadow, 0 0 #0000); }\n"
285
+ );
286
+ }
287
+
288
+ // --- Motion family (Round 2: tailwind-support `motion` track) -------------
289
+
290
+ #[test]
291
+ fn transform_keyword_and_none() {
292
+ assert_eq!(
293
+ css(&["transform"]),
294
+ ".transform { transform: translate(0, 0) rotate(0) skewX(0) skewY(0) scaleX(1) scaleY(1); }\n"
295
+ );
296
+ assert_eq!(
297
+ css(&["transform-none"]),
298
+ ".transform-none { transform: none; }\n"
299
+ );
300
+ }
301
+
302
+ // --- Round 2: container-query marker utilities (aria-data-container) -------
303
+ //
304
+ // The `@container` marker and its named `@container/<name>` form are base
305
+ // utilities (the `@sm:`/`@md:`/`@lg:` query variants live in variants.rs /
306
+ // emit.rs and are exercised in tests/emit.rs).
307
+
308
+ #[test]
309
+ fn container_marker_emits_inline_size() {
310
+ assert_eq!(
311
+ css(&["@container"]),
312
+ ".@container { container-type: inline-size; }\n"
313
+ );
314
+ }
315
+
316
+ #[test]
317
+ fn divide_y_bare_defaults_to_1px() {
318
+ assert_eq!(
319
+ css(&["divide-y"]),
320
+ ".divide-y { & > * + * { border-block-width: 1px; } }\n"
321
+ );
322
+ }
323
+
324
+ #[test]
325
+ fn translate_x_uses_spacing_scale() {
326
+ assert_eq!(
327
+ css(&["translate-x-2"]),
328
+ ".translate-x-2 { transform: translateX(0.5rem); }\n"
329
+ );
330
+ }
331
+
332
+ #[test]
333
+ fn ring_default_is_3px() {
334
+ // The bare `ring` keyword is the 3px default and must match BEFORE the
335
+ // color path (it never collides with `ring-<color>`).
336
+ assert!(css(&["ring"]).contains("calc(3px + var(--tw-ring-offset-width))"));
337
+ assert!(css(&["ring"]).starts_with(".ring {"));
338
+ }
339
+
340
+ #[test]
341
+ fn ring_zero_emits_zero_width_ring() {
342
+ assert!(css(&["ring-0"]).contains("calc(0px + var(--tw-ring-offset-width))"));
343
+ }
344
+
345
+ #[test]
346
+ fn ring_inset_flips_inset_slot() {
347
+ assert_eq!(css(&["ring-inset"]), ".ring-inset { --tw-ring-inset: inset; }\n");
348
+ }
349
+
350
+ #[test]
351
+ fn ring_offset_n_sets_offset_width() {
352
+ assert_eq!(
353
+ css(&["ring-offset-2"]),
354
+ ".ring-offset-2 { --tw-ring-offset-width: 2px; }\n"
355
+ );
356
+ assert_eq!(
357
+ css(&["ring-offset-8"]),
358
+ ".ring-offset-8 { --tw-ring-offset-width: 8px; }\n"
359
+ );
360
+ }
361
+
362
+ #[test]
363
+ fn negative_translate_x_emits_negative_value() {
364
+ assert_eq!(
365
+ css(&["-translate-x-2"]),
366
+ ".-translate-x-2 { transform: translateX(-0.5rem); }\n"
367
+ );
368
+ }
369
+
370
+ #[test]
371
+ fn divide_y_2_emits_border_block_width() {
372
+ assert_eq!(
373
+ css(&["divide-y-2"]),
374
+ ".divide-y-2 { & > * + * { border-block-width: 2px; } }\n"
375
+ );
376
+ }
377
+
378
+ #[test]
379
+ fn negative_translate_y_emits_negative_value() {
380
+ assert_eq!(
381
+ css(&["-translate-y-4"]),
382
+ ".-translate-y-4 { transform: translateY(-1rem); }\n"
383
+ );
384
+ }
385
+
386
+ #[test]
387
+ fn divide_x_4_emits_border_inline_width() {
388
+ assert_eq!(
389
+ css(&["divide-x-4"]),
390
+ ".divide-x-4 { & > * + * { border-inline-width: 4px; } }\n"
391
+ );
392
+ }
393
+
394
+ #[test]
395
+ fn rotate_emits_degrees() {
396
+ assert_eq!(
397
+ css(&["rotate-45"]),
398
+ ".rotate-45 { transform: rotate(45deg); }\n"
399
+ );
400
+ }
401
+
402
+ #[test]
403
+ fn divide_x_0_emits_zero_width() {
404
+ assert_eq!(
405
+ css(&["divide-x-0"]),
406
+ ".divide-x-0 { & > * + * { border-inline-width: 0; } }\n"
407
+ );
408
+ }
409
+
410
+ #[test]
411
+ fn negative_rotate_emits_negative_degrees() {
412
+ assert_eq!(
413
+ css(&["-rotate-45"]),
414
+ ".-rotate-45 { transform: rotate(-45deg); }\n"
415
+ );
416
+ }
417
+
418
+ #[test]
419
+ fn divide_x_8_emits_border_inline_width() {
420
+ assert_eq!(
421
+ css(&["divide-x-8"]),
422
+ ".divide-x-8 { & > * + * { border-inline-width: 8px; } }\n"
423
+ );
424
+ }
425
+
426
+ #[test]
427
+ fn scale_maps_percent_to_factor() {
428
+ assert_eq!(
429
+ css(&["scale-105"]),
430
+ ".scale-105 { transform: scale(1.05); }\n"
431
+ );
432
+ assert_eq!(
433
+ css(&["scale-x-50"]),
434
+ ".scale-x-50 { transform: scaleX(0.5); }\n"
435
+ );
436
+ assert_eq!(
437
+ css(&["scale-y-100"]),
438
+ ".scale-y-100 { transform: scaleY(1); }\n"
439
+ );
440
+ }
441
+
442
+ #[test]
443
+ fn divide_reverse_sets_custom_property() {
444
+ assert_eq!(
445
+ css(&["divide-x-reverse"]),
446
+ ".divide-x-reverse { & > * + * { --tw-divide-x-reverse: 1; } }\n"
447
+ );
448
+ assert_eq!(
449
+ css(&["divide-y-reverse"]),
450
+ ".divide-y-reverse { & > * + * { --tw-divide-y-reverse: 1; } }\n"
451
+ );
452
+ }
453
+
454
+ #[test]
455
+ fn transition_colors_emits_property_duration_timing() {
456
+ assert_eq!(
457
+ css(&["transition-colors"]),
458
+ ".transition-colors { transition-property: color, background-color, border-color, text-decoration-color, fill, stroke; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }\n"
459
+ );
460
+ }
461
+
462
+ #[test]
463
+ fn arbitrary_position_and_leading_still_work() {
464
+ // Regression: arbitrary forms (arbitrary_prop) untouched by the named scale.
465
+ assert_eq!(css(&["top-[1rem]"]), ".top-[1rem] { top: 1rem; }\n");
466
+ assert_eq!(css(&["leading-[2]"]), ".leading-[2] { line-height: 2; }\n");
467
+ }
468
+
469
+ #[test]
470
+ fn ring_color_still_routes_to_ring_color_var() {
471
+ // Regression: adding the WIDTH side must NOT break the existing COLOR path.
472
+ assert_eq!(
473
+ css(&["ring-blue-500"]),
474
+ ".ring-blue-500 { --tw-ring-color: var(--color-blue-500); }\n"
475
+ );
476
+ // Brand ring token too.
477
+ assert_eq!(
478
+ css(&["ring-primary"]),
479
+ ".ring-primary { --tw-ring-color: var(--color-primary); }\n"
480
+ );
481
+ }
482
+
483
+ #[test]
484
+ fn transition_none_and_transform_variants() {
485
+ assert_eq!(
486
+ css(&["transition-none"]),
487
+ ".transition-none { transition-property: none; }\n"
488
+ );
489
+ assert!(css(&["transition-transform"]).contains("transition-property: transform;"));
490
+ assert!(css(&["transition-opacity"]).contains("transition-property: opacity;"));
491
+ assert!(css(&["transition-all"]).contains("transition-property: all;"));
492
+ assert!(css(&["transition"]).contains("transition-duration: 150ms;"));
493
+ }
494
+
495
+ #[test]
496
+ fn duration_emits_milliseconds() {
497
+ assert_eq!(
498
+ css(&["duration-300"]),
499
+ ".duration-300 { transition-duration: 300ms; }\n"
500
+ );
501
+ }
502
+
503
+ #[test]
504
+ fn ease_timing_functions() {
505
+ assert_eq!(
506
+ css(&["ease-linear"]),
507
+ ".ease-linear { transition-timing-function: linear; }\n"
508
+ );
509
+ assert!(css(&["ease-in"]).contains("cubic-bezier(0.4, 0, 1, 1)"));
510
+ assert!(css(&["ease-out"]).contains("cubic-bezier(0, 0, 0.2, 1)"));
511
+ assert!(css(&["ease-in-out"]).contains("cubic-bezier(0.4, 0, 0.2, 1)"));
512
+ }
513
+
514
+ #[test]
515
+ fn animate_spin_emits_animation_and_keyframes() {
516
+ // The animation shorthand AND its hoisted @keyframes (sibling rule).
517
+ assert_eq!(
518
+ css(&["animate-spin"]),
519
+ ".animate-spin { animation: spin 1s linear infinite; }\n\
520
+ @keyframes spin { to { transform: rotate(360deg); } }\n"
521
+ );
522
+ }
523
+
524
+ #[test]
525
+ fn animate_none_has_no_keyframes() {
526
+ assert_eq!(
527
+ css(&["animate-none"]),
528
+ ".animate-none { animation: none; }\n"
529
+ );
530
+ }
531
+
532
+ #[test]
533
+ fn animate_ping_pulse_bounce_emit_keyframes() {
534
+ assert!(css(&["animate-ping"]).contains("@keyframes ping"));
535
+ assert!(css(&["animate-pulse"]).contains("@keyframes pulse"));
536
+ assert!(css(&["animate-bounce"]).contains("@keyframes bounce"));
537
+ assert!(css(&["animate-bounce"]).contains("animation: bounce 1s infinite;"));
538
+ }
539
+
540
+ #[test]
541
+ fn named_container_marker_emits_type_and_name() {
542
+ assert_eq!(
543
+ css(&["@container/sidebar"]),
544
+ ".@container/sidebar { container-type: inline-size; container-name: sidebar; }\n"
545
+ );
546
+ }
@@ -1,18 +1,31 @@
1
1
  //#region src/runtime/cn-conflict-map.generated.ts
2
2
  /** Prefix → conflict-group key, sorted longest-prefix-first. */
3
3
  const CONFLICT_GROUPS = {
4
+ "translate-x": "translate",
5
+ "translate-y": "translate",
6
+ "grid-cols": "grid-template-columns",
7
+ "grid-rows": "grid-template-rows",
8
+ "col-span": "grid-column",
9
+ "row-span": "grid-row",
10
+ duration: "transition-duration",
11
+ "space-x": "space-x",
12
+ "space-y": "space-y",
4
13
  outline: "outline-color",
5
14
  opacity: "opacity",
6
15
  rounded: "border-radius",
16
+ "scale-x": "scale",
17
+ "scale-y": "scale",
7
18
  border: "border-color",
8
19
  stroke: "stroke",
9
20
  shadow: "box-shadow",
21
+ rotate: "rotate",
10
22
  "gap-x": "column-gap",
11
23
  "gap-y": "row-gap",
12
24
  "min-w": "min-width",
13
25
  "max-w": "max-width",
14
26
  "min-h": "min-height",
15
27
  "max-h": "max-height",
28
+ scale: "scale",
16
29
  text: "color",
17
30
  fill: "fill",
18
31
  ring: "--tw-ring-color",
@@ -1 +1 @@
1
- {"version":3,"file":"cn.js","names":[],"sources":["../../src/runtime/cn-conflict-map.generated.ts","../../src/runtime/cn.ts"],"sourcesContent":["// AUTO-GENERATED by scripts/gen-cn-conflict-map.ts — DO NOT EDIT.\n// Source of truth: aihu-css-core `tokens::conflict_groups()` (the utility\n// registry). Regenerate with `bun run gen:cn-map`. Hand-edits will be lost.\n//\n// Maps a utility class PREFIX to the CSS property group it controls. Two\n// classes conflict (last wins) when they resolve to the same group.\n\n/** Prefix → conflict-group key, sorted longest-prefix-first. */\nexport const CONFLICT_GROUPS: Record<string, string> = {\n outline: 'outline-color',\n opacity: 'opacity',\n rounded: 'border-radius',\n border: 'border-color',\n stroke: 'stroke',\n shadow: 'box-shadow',\n 'gap-x': 'column-gap',\n 'gap-y': 'row-gap',\n 'min-w': 'min-width',\n 'max-w': 'max-width',\n 'min-h': 'min-height',\n 'max-h': 'max-height',\n text: 'color',\n fill: 'fill',\n ring: '--tw-ring-color',\n font: 'font-weight',\n gap: 'gap',\n px: 'padding-inline',\n py: 'padding-block',\n pt: 'padding-top',\n pr: 'padding-right',\n pb: 'padding-bottom',\n pl: 'padding-left',\n mx: 'margin-inline',\n my: 'margin-block',\n mt: 'margin-top',\n mr: 'margin-right',\n mb: 'margin-bottom',\n ml: 'margin-left',\n bg: 'background-color',\n p: 'padding',\n m: 'margin',\n w: 'width',\n h: 'height',\n z: 'z-index',\n}\n","/**\n * `@aihu/css-engine/runtime/cn` — the class-merge runtime helper (Plan 3 Task 9).\n *\n * `cn(...inputs)` merges class strings / arrays / conditionals into a single\n * deduplicated class string, resolving Tailwind-style conflicts last-wins per\n * property group (`cn('p-2', 'p-4')` → `'p-4'`).\n *\n * The conflict map (`CONFLICT_GROUPS`) is GENERATED at engine build time from\n * the utility registry (`scripts/gen-cn-conflict-map.ts` → Rust\n * `tokens::conflict_groups()`), NOT hand-maintained — so it never drifts from\n * the utility table. Separate < 1 KB gz sub-export from `runtime/progressive`\n * (Risk #4 size-split).\n *\n * This is the runtime-merge helper for consumer-provided overrides (spec §9.3):\n * recipes use static utility strings at compile time; `cn()` is only for the\n * runtime override case.\n */\nimport { CONFLICT_GROUPS } from './cn-conflict-map.generated.ts'\n\n/** A class value: string, falsy (dropped), or a nested array of the same. */\nexport type ClassValue = string | number | null | undefined | false | ClassValue[]\n\n/** Flatten the (possibly nested / conditional) inputs into a token list. */\nfunction tokens(inputs: ClassValue[], out: string[]): void {\n for (const input of inputs) {\n if (!input) continue\n if (Array.isArray(input)) {\n tokens(input, out)\n } else {\n for (const t of String(input).split(' ')) {\n if (t) out.push(t)\n }\n }\n }\n}\n\n/**\n * The conflict-group key for a class, or the class itself when it belongs to no\n * known group (so unrelated classes always coexist). Strips variant prefixes\n * (`md:`, `hover:`, …) so `hover:p-2` and `hover:p-4` still conflict, while\n * `p-2` and `hover:p-4` do not (different variant scope).\n */\nfunction groupKey(cls: string): string {\n const colon = cls.lastIndexOf(':')\n const variant = colon === -1 ? '' : cls.slice(0, colon + 1)\n const base = colon === -1 ? cls : cls.slice(colon + 1)\n const dash = base.indexOf('-')\n // The prefix is the segment before the first dash (`bg-red-500` → `bg`); for\n // dashless bases (`flex`) there is no group, so the class keys to itself.\n const prefix = dash === -1 ? base : base.slice(0, dash)\n const group = CONFLICT_GROUPS[prefix]\n return group ? `${variant}${group}` : cls\n}\n\n/**\n * Merge class values, resolving last-wins conflicts per property group.\n *\n * @example cn('p-2', 'p-4') // 'p-4'\n * @example cn('a', false && 'b', ['c']) // 'a c'\n * @example cn('bg-red-500', 'bg-blue-500') // 'bg-blue-500'\n */\nexport function cn(...inputs: ClassValue[]): string {\n const flat: string[] = []\n tokens(inputs, flat)\n\n // Last occurrence per group key wins; preserve final order by re-scanning.\n const winner = new Map<string, string>()\n for (const t of flat) winner.set(groupKey(t), t)\n\n const seen = new Set<string>()\n const result: string[] = []\n for (const t of flat) {\n const key = groupKey(t)\n if (winner.get(key) === t && !seen.has(t)) {\n seen.add(t)\n result.push(t)\n }\n }\n return result.join(' ')\n}\n"],"mappings":";;AAQA,MAAa,kBAA0C;CACrD,SAAS;CACT,SAAS;CACT,SAAS;CACT,QAAQ;CACR,QAAQ;CACR,QAAQ;CACR,SAAS;CACT,SAAS;CACT,SAAS;CACT,SAAS;CACT,SAAS;CACT,SAAS;CACT,MAAM;CACN,MAAM;CACN,MAAM;CACN,MAAM;CACN,KAAK;CACL,IAAI;CACJ,IAAI;CACJ,IAAI;CACJ,IAAI;CACJ,IAAI;CACJ,IAAI;CACJ,IAAI;CACJ,IAAI;CACJ,IAAI;CACJ,IAAI;CACJ,IAAI;CACJ,IAAI;CACJ,IAAI;CACJ,GAAG;CACH,GAAG;CACH,GAAG;CACH,GAAG;CACH,GAAG;CACJ;;;;;;;;;;;;;;;;;;;;;ACrBD,SAAS,OAAO,QAAsB,KAAqB;CACzD,KAAK,MAAM,SAAS,QAAQ;EAC1B,IAAI,CAAC,OAAO;EACZ,IAAI,MAAM,QAAQ,MAAM,EACtB,OAAO,OAAO,IAAI;OAElB,KAAK,MAAM,KAAK,OAAO,MAAM,CAAC,MAAM,IAAI,EACtC,IAAI,GAAG,IAAI,KAAK,EAAE;;;;;;;;;AAY1B,SAAS,SAAS,KAAqB;CACrC,MAAM,QAAQ,IAAI,YAAY,IAAI;CAClC,MAAM,UAAU,UAAU,KAAK,KAAK,IAAI,MAAM,GAAG,QAAQ,EAAE;CAC3D,MAAM,OAAO,UAAU,KAAK,MAAM,IAAI,MAAM,QAAQ,EAAE;CACtD,MAAM,OAAO,KAAK,QAAQ,IAAI;CAI9B,MAAM,QAAQ,gBADC,SAAS,KAAK,OAAO,KAAK,MAAM,GAAG,KAAK;CAEvD,OAAO,QAAQ,GAAG,UAAU,UAAU;;;;;;;;;AAUxC,SAAgB,GAAG,GAAG,QAA8B;CAClD,MAAM,OAAiB,EAAE;CACzB,OAAO,QAAQ,KAAK;CAGpB,MAAM,yBAAS,IAAI,KAAqB;CACxC,KAAK,MAAM,KAAK,MAAM,OAAO,IAAI,SAAS,EAAE,EAAE,EAAE;CAEhD,MAAM,uBAAO,IAAI,KAAa;CAC9B,MAAM,SAAmB,EAAE;CAC3B,KAAK,MAAM,KAAK,MAAM;EACpB,MAAM,MAAM,SAAS,EAAE;EACvB,IAAI,OAAO,IAAI,IAAI,KAAK,KAAK,CAAC,KAAK,IAAI,EAAE,EAAE;GACzC,KAAK,IAAI,EAAE;GACX,OAAO,KAAK,EAAE;;;CAGlB,OAAO,OAAO,KAAK,IAAI"}
1
+ {"version":3,"file":"cn.js","names":[],"sources":["../../src/runtime/cn-conflict-map.generated.ts","../../src/runtime/cn.ts"],"sourcesContent":["// AUTO-GENERATED by scripts/gen-cn-conflict-map.ts — DO NOT EDIT.\n// Source of truth: aihu-css-core `tokens::conflict_groups()` (the utility\n// registry). Regenerate with `bun run gen:cn-map`. Hand-edits will be lost.\n//\n// Maps a utility class PREFIX to the CSS property group it controls. Two\n// classes conflict (last wins) when they resolve to the same group.\n\n/** Prefix → conflict-group key, sorted longest-prefix-first. */\nexport const CONFLICT_GROUPS: Record<string, string> = {\n 'translate-x': 'translate',\n 'translate-y': 'translate',\n 'grid-cols': 'grid-template-columns',\n 'grid-rows': 'grid-template-rows',\n 'col-span': 'grid-column',\n 'row-span': 'grid-row',\n duration: 'transition-duration',\n 'space-x': 'space-x',\n 'space-y': 'space-y',\n outline: 'outline-color',\n opacity: 'opacity',\n rounded: 'border-radius',\n 'scale-x': 'scale',\n 'scale-y': 'scale',\n border: 'border-color',\n stroke: 'stroke',\n shadow: 'box-shadow',\n rotate: 'rotate',\n 'gap-x': 'column-gap',\n 'gap-y': 'row-gap',\n 'min-w': 'min-width',\n 'max-w': 'max-width',\n 'min-h': 'min-height',\n 'max-h': 'max-height',\n scale: 'scale',\n text: 'color',\n fill: 'fill',\n ring: '--tw-ring-color',\n font: 'font-weight',\n gap: 'gap',\n px: 'padding-inline',\n py: 'padding-block',\n pt: 'padding-top',\n pr: 'padding-right',\n pb: 'padding-bottom',\n pl: 'padding-left',\n mx: 'margin-inline',\n my: 'margin-block',\n mt: 'margin-top',\n mr: 'margin-right',\n mb: 'margin-bottom',\n ml: 'margin-left',\n bg: 'background-color',\n p: 'padding',\n m: 'margin',\n w: 'width',\n h: 'height',\n z: 'z-index',\n}\n","/**\n * `@aihu/css-engine/runtime/cn` — the class-merge runtime helper (Plan 3 Task 9).\n *\n * `cn(...inputs)` merges class strings / arrays / conditionals into a single\n * deduplicated class string, resolving Tailwind-style conflicts last-wins per\n * property group (`cn('p-2', 'p-4')` → `'p-4'`).\n *\n * The conflict map (`CONFLICT_GROUPS`) is GENERATED at engine build time from\n * the utility registry (`scripts/gen-cn-conflict-map.ts` → Rust\n * `tokens::conflict_groups()`), NOT hand-maintained — so it never drifts from\n * the utility table. Separate < 1 KB gz sub-export from `runtime/progressive`\n * (Risk #4 size-split).\n *\n * This is the runtime-merge helper for consumer-provided overrides (spec §9.3):\n * recipes use static utility strings at compile time; `cn()` is only for the\n * runtime override case.\n */\nimport { CONFLICT_GROUPS } from './cn-conflict-map.generated.ts'\n\n/** A class value: string, falsy (dropped), or a nested array of the same. */\nexport type ClassValue = string | number | null | undefined | false | ClassValue[]\n\n/** Flatten the (possibly nested / conditional) inputs into a token list. */\nfunction tokens(inputs: ClassValue[], out: string[]): void {\n for (const input of inputs) {\n if (!input) continue\n if (Array.isArray(input)) {\n tokens(input, out)\n } else {\n for (const t of String(input).split(' ')) {\n if (t) out.push(t)\n }\n }\n }\n}\n\n/**\n * The conflict-group key for a class, or the class itself when it belongs to no\n * known group (so unrelated classes always coexist). Strips variant prefixes\n * (`md:`, `hover:`, …) so `hover:p-2` and `hover:p-4` still conflict, while\n * `p-2` and `hover:p-4` do not (different variant scope).\n */\nfunction groupKey(cls: string): string {\n const colon = cls.lastIndexOf(':')\n const variant = colon === -1 ? '' : cls.slice(0, colon + 1)\n const base = colon === -1 ? cls : cls.slice(colon + 1)\n const dash = base.indexOf('-')\n // The prefix is the segment before the first dash (`bg-red-500` → `bg`); for\n // dashless bases (`flex`) there is no group, so the class keys to itself.\n const prefix = dash === -1 ? base : base.slice(0, dash)\n const group = CONFLICT_GROUPS[prefix]\n return group ? `${variant}${group}` : cls\n}\n\n/**\n * Merge class values, resolving last-wins conflicts per property group.\n *\n * @example cn('p-2', 'p-4') // 'p-4'\n * @example cn('a', false && 'b', ['c']) // 'a c'\n * @example cn('bg-red-500', 'bg-blue-500') // 'bg-blue-500'\n */\nexport function cn(...inputs: ClassValue[]): string {\n const flat: string[] = []\n tokens(inputs, flat)\n\n // Last occurrence per group key wins; preserve final order by re-scanning.\n const winner = new Map<string, string>()\n for (const t of flat) winner.set(groupKey(t), t)\n\n const seen = new Set<string>()\n const result: string[] = []\n for (const t of flat) {\n const key = groupKey(t)\n if (winner.get(key) === t && !seen.has(t)) {\n seen.add(t)\n result.push(t)\n }\n }\n return result.join(' ')\n}\n"],"mappings":";;AAQA,MAAa,kBAA0C;CACrD,eAAe;CACf,eAAe;CACf,aAAa;CACb,aAAa;CACb,YAAY;CACZ,YAAY;CACZ,UAAU;CACV,WAAW;CACX,WAAW;CACX,SAAS;CACT,SAAS;CACT,SAAS;CACT,WAAW;CACX,WAAW;CACX,QAAQ;CACR,QAAQ;CACR,QAAQ;CACR,QAAQ;CACR,SAAS;CACT,SAAS;CACT,SAAS;CACT,SAAS;CACT,SAAS;CACT,SAAS;CACT,OAAO;CACP,MAAM;CACN,MAAM;CACN,MAAM;CACN,MAAM;CACN,KAAK;CACL,IAAI;CACJ,IAAI;CACJ,IAAI;CACJ,IAAI;CACJ,IAAI;CACJ,IAAI;CACJ,IAAI;CACJ,IAAI;CACJ,IAAI;CACJ,IAAI;CACJ,IAAI;CACJ,IAAI;CACJ,IAAI;CACJ,GAAG;CACH,GAAG;CACH,GAAG;CACH,GAAG;CACH,GAAG;CACJ;;;;;;;;;;;;;;;;;;;;;AClCD,SAAS,OAAO,QAAsB,KAAqB;CACzD,KAAK,MAAM,SAAS,QAAQ;EAC1B,IAAI,CAAC,OAAO;EACZ,IAAI,MAAM,QAAQ,MAAM,EACtB,OAAO,OAAO,IAAI;OAElB,KAAK,MAAM,KAAK,OAAO,MAAM,CAAC,MAAM,IAAI,EACtC,IAAI,GAAG,IAAI,KAAK,EAAE;;;;;;;;;AAY1B,SAAS,SAAS,KAAqB;CACrC,MAAM,QAAQ,IAAI,YAAY,IAAI;CAClC,MAAM,UAAU,UAAU,KAAK,KAAK,IAAI,MAAM,GAAG,QAAQ,EAAE;CAC3D,MAAM,OAAO,UAAU,KAAK,MAAM,IAAI,MAAM,QAAQ,EAAE;CACtD,MAAM,OAAO,KAAK,QAAQ,IAAI;CAI9B,MAAM,QAAQ,gBADC,SAAS,KAAK,OAAO,KAAK,MAAM,GAAG,KAAK;CAEvD,OAAO,QAAQ,GAAG,UAAU,UAAU;;;;;;;;;AAUxC,SAAgB,GAAG,GAAG,QAA8B;CAClD,MAAM,OAAiB,EAAE;CACzB,OAAO,QAAQ,KAAK;CAGpB,MAAM,yBAAS,IAAI,KAAqB;CACxC,KAAK,MAAM,KAAK,MAAM,OAAO,IAAI,SAAS,EAAE,EAAE,EAAE;CAEhD,MAAM,uBAAO,IAAI,KAAa;CAC9B,MAAM,SAAmB,EAAE;CAC3B,KAAK,MAAM,KAAK,MAAM;EACpB,MAAM,MAAM,SAAS,EAAE;EACvB,IAAI,OAAO,IAAI,IAAI,KAAK,KAAK,CAAC,KAAK,IAAI,EAAE,EAAE;GACzC,KAAK,IAAI,EAAE;GACX,OAAO,KAAK,EAAE;;;CAGlB,OAAO,OAAO,KAAK,IAAI"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aihu/css-engine",
3
- "version": "0.2.5",
3
+ "version": "0.3.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",