@aihu/css-engine 0.2.5 → 0.4.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.
Files changed (52) hide show
  1. package/README.md +27 -22
  2. package/crates/aihu-css-core/src/apply.rs +314 -0
  3. package/crates/aihu-css-core/src/bin/main.rs +8 -7
  4. package/crates/aihu-css-core/src/cache.rs +8 -5
  5. package/crates/aihu-css-core/src/emit.rs +195 -36
  6. package/crates/aihu-css-core/src/lib.rs +15 -2
  7. package/crates/aihu-css-core/src/palette.rs +301 -0
  8. package/crates/aihu-css-core/src/style_parser.rs +587 -0
  9. package/crates/aihu-css-core/src/theme.rs +14 -0
  10. package/crates/aihu-css-core/src/tokens.rs +1196 -29
  11. package/crates/aihu-css-core/src/variants.rs +251 -3
  12. package/crates/aihu-css-core/tests/apply.rs +203 -0
  13. package/crates/aihu-css-core/tests/apply_regression.rs +150 -0
  14. package/crates/aihu-css-core/tests/binary_error.rs +61 -0
  15. package/crates/aihu-css-core/tests/cache.rs +8 -8
  16. package/crates/aihu-css-core/tests/emit.rs +284 -17
  17. package/crates/aihu-css-core/tests/parity.rs +274 -0
  18. package/crates/aihu-css-core/tests/progressive_snapshot.rs +8 -8
  19. package/crates/aihu-css-core/tests/scoped_snapshot.rs +80 -8
  20. package/crates/aihu-css-core/tests/snapshots/apply__apply_inside_nested_rule.snap +11 -0
  21. package/crates/aihu-css-core/tests/snapshots/apply__arbitrary_value_utility_in_apply.snap +8 -0
  22. package/crates/aihu-css-core/tests/snapshots/apply__arbitrary_value_variant_in_apply.snap +10 -0
  23. package/crates/aihu-css-core/tests/snapshots/apply__base_utility_inlines_declarations.snap +9 -0
  24. package/crates/aihu-css-core/tests/snapshots/apply__dark_variant_cascade_in_apply.snap +10 -0
  25. package/crates/aihu-css-core/tests/snapshots/apply__data_attribute_variant.snap +10 -0
  26. package/crates/aihu-css-core/tests/snapshots/apply__multi_token_apply.snap +11 -0
  27. package/crates/aihu-css-core/tests/snapshots/apply__multiple_apply_directives_per_rule.snap +12 -0
  28. package/crates/aihu-css-core/tests/snapshots/apply__responsive_variant_wraps_media.snap +12 -0
  29. package/crates/aihu-css-core/tests/snapshots/apply__single_variant_lifts_to_nested_rule.snap +10 -0
  30. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__anchor_snapshot.snap +1 -0
  31. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__popover_snapshot.snap +1 -0
  32. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__text_balance_snapshot.snap +1 -0
  33. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__view_transition_snapshot.snap +1 -0
  34. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_animate_spin_hoists_keyframes.snap +25 -0
  35. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_divide_y_nested_rule.snap +24 -0
  36. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_output_for_sfc.snap +1 -0
  37. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_space_y_nested_rule.snap +24 -0
  38. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_transition_and_transform.snap +26 -0
  39. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_with_authored_style_block.snap +6 -2
  40. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_with_global_style_block.snap +5 -2
  41. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__standard_variants.snap +1 -0
  42. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__theme_default_vs_override.snap +2 -0
  43. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__wc_native_variants.snap +1 -0
  44. package/crates/aihu-css-core/tests/style_parser.rs +257 -0
  45. package/crates/aihu-css-core/tests/tokens.rs +526 -7
  46. package/dist/index.d.ts +0 -9
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +26 -18
  49. package/dist/index.js.map +1 -1
  50. package/dist/runtime/cn.js +13 -0
  51. package/dist/runtime/cn.js.map +1 -1
  52. package/package.json +6 -6
@@ -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,514 @@ 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
+ }
547
+
548
+ // ── Issue #280: dictionary misses, grid arbitrary, opacity modifiers ─────────
549
+
550
+ #[test]
551
+ fn font_family_utilities_emit() {
552
+ assert_eq!(css(&["font-mono"]), ".font-mono { font-family: var(--font-mono); }\n");
553
+ assert_eq!(css(&["font-sans"]), ".font-sans { font-family: var(--font-sans); }\n");
554
+ }
555
+
556
+ #[test]
557
+ fn bare_directional_borders_emit_1px() {
558
+ assert_eq!(css(&["border-t"]), ".border-t { border-top-width: 1px; }\n");
559
+ assert_eq!(css(&["border-r"]), ".border-r { border-right-width: 1px; }\n");
560
+ assert_eq!(css(&["border-b"]), ".border-b { border-bottom-width: 1px; }\n");
561
+ assert_eq!(css(&["border-l"]), ".border-l { border-left-width: 1px; }\n");
562
+ assert_eq!(css(&["border-x"]), ".border-x { border-inline-width: 1px; }\n");
563
+ assert_eq!(css(&["border-y"]), ".border-y { border-block-width: 1px; }\n");
564
+ }
565
+
566
+ #[test]
567
+ fn grid_arbitrary_template_columns_emits() {
568
+ assert_eq!(
569
+ css(&["grid-cols-[2fr_1fr_1fr_1.5fr_1.5fr]"]),
570
+ ".grid-cols-[2fr_1fr_1fr_1.5fr_1.5fr] { grid-template-columns: 2fr 1fr 1fr 1.5fr 1.5fr; }\n"
571
+ );
572
+ assert_eq!(
573
+ css(&["grid-rows-[auto_1fr]"]),
574
+ ".grid-rows-[auto_1fr] { grid-template-rows: auto 1fr; }\n"
575
+ );
576
+ }
577
+
578
+ #[test]
579
+ fn color_opacity_modifier_emits_color_mix() {
580
+ assert_eq!(
581
+ css(&["bg-accent/15"]),
582
+ ".bg-accent/15 { background-color: color-mix(in oklab, var(--color-accent) 15%, transparent); }\n"
583
+ );
584
+ assert_eq!(
585
+ css(&["bg-primary/50"]),
586
+ ".bg-primary/50 { background-color: color-mix(in oklab, var(--color-primary) 50%, transparent); }\n"
587
+ );
588
+ assert_eq!(
589
+ css(&["text-red-500/30"]),
590
+ ".text-red-500/30 { color: color-mix(in oklab, var(--color-red-500) 30%, transparent); }\n"
591
+ );
592
+ }
593
+
594
+ #[test]
595
+ fn sizing_fraction_not_treated_as_opacity() {
596
+ // `w-1/2` is a width fraction, not a color opacity — must stay 50%.
597
+ assert_eq!(css(&["w-1/2"]), ".w-1/2 { width: 50%; }\n");
598
+ }
package/dist/index.d.ts CHANGED
@@ -21,15 +21,6 @@ import { i as defineStylePack, n as StylePackInput, r as TokenMap, t as StylePac
21
21
  * still rejects a zero-byte placeholder.
22
22
  */
23
23
  declare function isUsableExecutable(candidate: string): boolean;
24
- /**
25
- * Compile a list of utility class names to CSS.
26
- *
27
- * Plan 1 bootstrap — supports a hardcoded subset; see crates/aihu-css-core/src/tokens.rs.
28
- * Plan 2 wires the AST scanner so callers pass `.aihu` SFC ASTs instead of raw class lists.
29
- *
30
- * @param classes - utility class names like `['bg-primary', 'p-4']`
31
- * @returns CSS string with one rule per known class
32
- */
33
24
  declare function compile(classes: string[]): string;
34
25
  /**
35
26
  * Compile a `.aihu` SFC source string to scoped, shadow-DOM-embedded CSS.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","names":[],"sources":["../src/index.ts"],"mappings":";;;;;AA8FA;;;;;AAgHA;;;;;AA0BA;;;;;;;iBA1IgB,kBAAA,CAAmB,SAAA;;;;;;;;;;iBAgHnB,OAAA,CAAQ,OAAA;;;;;;;;;;;;;;iBA0BR,UAAA,CAAW,MAAA,UAAgB,EAAA"}
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../src/index.ts"],"mappings":";;;;;AA8FA;;;;;AA2IA;;;;;AAqBA;;;;;;;iBAhKgB,kBAAA,CAAmB,SAAA;AAAA,iBA2InB,OAAA,CAAQ,OAAA;;;;;;;;;;;;;;iBAqBR,UAAA,CAAW,MAAA,UAAgB,EAAA"}
package/dist/index.js CHANGED
@@ -114,17 +114,33 @@ function buildMissingBinaryError(descriptor, devCandidates) {
114
114
  * @param classes - utility class names like `['bg-primary', 'p-4']`
115
115
  * @returns CSS string with one rule per known class
116
116
  */
117
+ /**
118
+ * Spawn the native compiler, returning stdout on success. On a non-zero exit
119
+ * (the binary's R-RESULT error path) throw an `Error` carrying the binary's
120
+ * stderr message rather than letting `execFileSync`'s opaque status error
121
+ * surface. stderr is PIPED (not inherited) so the message lands in the thrown
122
+ * error instead of the parent's console.
123
+ */
124
+ function runBinary(bin, args, input) {
125
+ try {
126
+ return execFileSync(bin, args, {
127
+ input,
128
+ encoding: "utf-8",
129
+ stdio: [
130
+ "pipe",
131
+ "pipe",
132
+ "pipe"
133
+ ]
134
+ });
135
+ } catch (err) {
136
+ const e = err;
137
+ const detail = (typeof e.stderr === "string" ? e.stderr : e.stderr instanceof Buffer ? e.stderr.toString("utf-8") : "").trim() || e.message || "unknown error";
138
+ throw new Error(`[@aihu/css-engine] CSS compile failed: ${detail}`);
139
+ }
140
+ }
117
141
  function compile(classes) {
118
142
  if (classes.length === 0) return "";
119
- return execFileSync(resolveBinary(), [], {
120
- input: classes.join("\n"),
121
- encoding: "utf-8",
122
- stdio: [
123
- "pipe",
124
- "pipe",
125
- "inherit"
126
- ]
127
- });
143
+ return runBinary(resolveBinary(), [], classes.join("\n"));
128
144
  }
129
145
  /**
130
146
  * Compile a `.aihu` SFC source string to scoped, shadow-DOM-embedded CSS.
@@ -141,15 +157,7 @@ function compile(classes) {
141
157
  */
142
158
  function compileSfc(source, id) {
143
159
  const ast = compileToAst(source, id);
144
- return execFileSync(resolveBinary(), ["--ast-json"], {
145
- input: JSON.stringify(ast),
146
- encoding: "utf-8",
147
- stdio: [
148
- "pipe",
149
- "pipe",
150
- "inherit"
151
- ]
152
- });
160
+ return runBinary(resolveBinary(), ["--ast-json"], JSON.stringify(ast));
153
161
  }
154
162
  //#endregion
155
163
  export { compile, compileSfc, defineStylePack, isUsableExecutable };