@aihu/css-engine 0.3.0 → 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 (49) hide show
  1. package/README.md +11 -11
  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 +110 -30
  6. package/crates/aihu-css-core/src/lib.rs +10 -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/tokens.rs +625 -29
  10. package/crates/aihu-css-core/src/variants.rs +154 -7
  11. package/crates/aihu-css-core/tests/apply.rs +203 -0
  12. package/crates/aihu-css-core/tests/apply_regression.rs +150 -0
  13. package/crates/aihu-css-core/tests/binary_error.rs +61 -0
  14. package/crates/aihu-css-core/tests/cache.rs +8 -8
  15. package/crates/aihu-css-core/tests/emit.rs +95 -36
  16. package/crates/aihu-css-core/tests/parity.rs +274 -0
  17. package/crates/aihu-css-core/tests/progressive_snapshot.rs +8 -8
  18. package/crates/aihu-css-core/tests/scoped_snapshot.rs +49 -11
  19. package/crates/aihu-css-core/tests/snapshots/apply__apply_inside_nested_rule.snap +11 -0
  20. package/crates/aihu-css-core/tests/snapshots/apply__arbitrary_value_utility_in_apply.snap +8 -0
  21. package/crates/aihu-css-core/tests/snapshots/apply__arbitrary_value_variant_in_apply.snap +10 -0
  22. package/crates/aihu-css-core/tests/snapshots/apply__base_utility_inlines_declarations.snap +9 -0
  23. package/crates/aihu-css-core/tests/snapshots/apply__dark_variant_cascade_in_apply.snap +10 -0
  24. package/crates/aihu-css-core/tests/snapshots/apply__data_attribute_variant.snap +10 -0
  25. package/crates/aihu-css-core/tests/snapshots/apply__multi_token_apply.snap +11 -0
  26. package/crates/aihu-css-core/tests/snapshots/apply__multiple_apply_directives_per_rule.snap +12 -0
  27. package/crates/aihu-css-core/tests/snapshots/apply__responsive_variant_wraps_media.snap +12 -0
  28. package/crates/aihu-css-core/tests/snapshots/apply__single_variant_lifts_to_nested_rule.snap +10 -0
  29. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__anchor_snapshot.snap +1 -0
  30. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__popover_snapshot.snap +1 -0
  31. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__text_balance_snapshot.snap +1 -0
  32. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__view_transition_snapshot.snap +1 -0
  33. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_animate_spin_hoists_keyframes.snap +1 -1
  34. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_divide_y_nested_rule.snap +1 -0
  35. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_output_for_sfc.snap +1 -0
  36. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_space_y_nested_rule.snap +1 -0
  37. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_transition_and_transform.snap +1 -1
  38. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_with_authored_style_block.snap +6 -2
  39. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_with_global_style_block.snap +5 -2
  40. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__standard_variants.snap +1 -0
  41. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__theme_default_vs_override.snap +2 -0
  42. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__wc_native_variants.snap +1 -0
  43. package/crates/aihu-css-core/tests/style_parser.rs +257 -0
  44. package/crates/aihu-css-core/tests/tokens.rs +52 -0
  45. package/dist/index.d.ts +0 -9
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +26 -18
  48. package/dist/index.js.map +1 -1
  49. package/package.json +6 -6
@@ -1,6 +1,6 @@
1
1
  //! Scoped-output, variant, and @theme emission tests (Plan 2 Tasks 4–7).
2
2
 
3
- use aihu_css_core::{compile_sfc_scoped, parse_ast};
3
+ use aihu_css_core::{compile_sfc_scoped, parse_ast, CompileError};
4
4
 
5
5
  fn ast(json: &str) -> aihu_css_core::SfcAst {
6
6
  parse_ast(json).unwrap()
@@ -21,7 +21,7 @@ fn sfc_with_classes(classes: &str) -> aihu_css_core::SfcAst {
21
21
 
22
22
  #[test]
23
23
  fn scoped_output_has_no_bare_global_utility_sheet() {
24
- let css = compile_sfc_scoped(&sfc_with_classes("bg-primary p-4"));
24
+ let css = compile_sfc_scoped(&sfc_with_classes("bg-primary p-4")).unwrap();
25
25
  // The utility rules are class selectors inside the shadow <style>; they are
26
26
  // scoped by living in the shadow root. There is no separate global sheet.
27
27
  assert!(css.contains(".bg-primary"));
@@ -38,9 +38,13 @@ fn scoped_folds_authored_style_block() {
38
38
  "meta":{"name":"X"},
39
39
  "template":[{"kind":"element","tag":"div","attrs":[
40
40
  {"kind":"static","name":"class","value":"p-4"}],"children":[]}]}"#;
41
- let css = compile_sfc_scoped(&ast(json));
41
+ let css = compile_sfc_scoped(&ast(json)).unwrap();
42
42
  assert!(css.contains(".p-4"));
43
- assert!(css.contains(".extra { color: red; }"), "authored scoped @style folded in");
43
+ // Authored @style now flows through the shared parser (R-SHARED-PARSER) so
44
+ // the verbatim text is re-rendered (whitespace-normalized). Assert the
45
+ // selector + declaration survive rather than exact single-line formatting.
46
+ assert!(css.contains(".extra"), "authored scoped @style folded in: {css}");
47
+ assert!(css.contains("color: red;"), "authored declaration preserved: {css}");
44
48
  }
45
49
 
46
50
  #[test]
@@ -48,8 +52,10 @@ fn global_style_block_passes_through_edge_e6() {
48
52
  let json = r#"{"tag":"X","astVersion":1,
49
53
  "style":{"content":"body { margin: 0; }","scope":"global"},
50
54
  "meta":{"name":"X"},"template":null}"#;
51
- let css = compile_sfc_scoped(&ast(json));
52
- assert!(css.contains("body { margin: 0; }"));
55
+ let css = compile_sfc_scoped(&ast(json)).unwrap();
56
+ // Re-rendered through the shared parser (whitespace-normalized).
57
+ assert!(css.contains("body"), "global block selector preserved: {css}");
58
+ assert!(css.contains("margin: 0;"), "global declaration preserved: {css}");
53
59
  assert!(css.contains("unscoped"), "global block annotated as unscoped");
54
60
  }
55
61
 
@@ -57,27 +63,27 @@ fn global_style_block_passes_through_edge_e6() {
57
63
 
58
64
  #[test]
59
65
  fn host_variant_emits_host_rule() {
60
- let css = compile_sfc_scoped(&sfc_with_classes("host:bg-primary"));
66
+ let css = compile_sfc_scoped(&sfc_with_classes("host:bg-primary")).unwrap();
61
67
  assert!(css.contains(":host("), "host: → :host(...) selector: {css}");
62
68
  assert!(css.contains("background-color: var(--color-primary)"));
63
69
  }
64
70
 
65
71
  #[test]
66
72
  fn slotted_variants() {
67
- let css = compile_sfc_scoped(&sfc_with_classes("slotted:p-4 slotted-img:rounded-lg"));
73
+ let css = compile_sfc_scoped(&sfc_with_classes("slotted:p-4 slotted-img:rounded-lg")).unwrap();
68
74
  assert!(css.contains("::slotted("));
69
75
  assert!(css.contains("::slotted(img"));
70
76
  }
71
77
 
72
78
  #[test]
73
79
  fn part_variant_emits_part_selector() {
74
- let css = compile_sfc_scoped(&sfc_with_classes("part-thumb:bg-accent"));
80
+ let css = compile_sfc_scoped(&sfc_with_classes("part-thumb:bg-accent")).unwrap();
75
81
  assert!(css.contains("::part(thumb)"));
76
82
  }
77
83
 
78
84
  #[test]
79
85
  fn host_context_dark_uses_cascade_not_host_context() {
80
- let css = compile_sfc_scoped(&sfc_with_classes("host-context-dark:bg-surface"));
86
+ let css = compile_sfc_scoped(&sfc_with_classes("host-context-dark:bg-surface")).unwrap();
81
87
  assert!(
82
88
  !css.contains(":host-context("),
83
89
  "Firefox-safe: must NOT emit :host-context(): {css}"
@@ -90,34 +96,34 @@ fn host_context_dark_uses_cascade_not_host_context() {
90
96
 
91
97
  #[test]
92
98
  fn hover_variant_appends_pseudo_class() {
93
- let css = compile_sfc_scoped(&sfc_with_classes("hover:bg-primary"));
99
+ let css = compile_sfc_scoped(&sfc_with_classes("hover:bg-primary")).unwrap();
94
100
  assert!(css.contains(":hover"), "hover: → :hover pseudo-class: {css}");
95
101
  }
96
102
 
97
103
  #[test]
98
104
  fn dark_variant_no_host_context() {
99
- let css = compile_sfc_scoped(&sfc_with_classes("dark:bg-surface"));
105
+ let css = compile_sfc_scoped(&sfc_with_classes("dark:bg-surface")).unwrap();
100
106
  assert!(!css.contains(":host-context("), "dark: must NOT emit :host-context()");
101
107
  assert!(css.contains("dark cascade"));
102
108
  }
103
109
 
104
110
  #[test]
105
111
  fn responsive_md_wraps_media_query() {
106
- let css = compile_sfc_scoped(&sfc_with_classes("md:p-8"));
112
+ let css = compile_sfc_scoped(&sfc_with_classes("md:p-8")).unwrap();
107
113
  assert!(css.contains("@media (min-width:"), "md: → @media: {css}");
108
114
  assert!(css.contains("padding: 2rem"));
109
115
  }
110
116
 
111
117
  #[test]
112
118
  fn arbitrary_selector_variant() {
113
- let css = compile_sfc_scoped(&sfc_with_classes("[&>div]:text-primary"));
119
+ let css = compile_sfc_scoped(&sfc_with_classes("[&>div]:text-primary")).unwrap();
114
120
  assert!(css.contains(">div"), "[&>div]: → child selector: {css}");
115
121
  assert!(css.contains("color: var(--color-primary)"));
116
122
  }
117
123
 
118
124
  #[test]
119
125
  fn stacked_variants_md_hover() {
120
- let css = compile_sfc_scoped(&sfc_with_classes("md:hover:bg-primary"));
126
+ let css = compile_sfc_scoped(&sfc_with_classes("md:hover:bg-primary")).unwrap();
121
127
  assert!(css.contains("@media (min-width:"));
122
128
  assert!(css.contains(":hover"));
123
129
  }
@@ -126,7 +132,7 @@ fn stacked_variants_md_hover() {
126
132
 
127
133
  #[test]
128
134
  fn group_hover_emits_ancestor_state_selector() {
129
- let css = compile_sfc_scoped(&sfc_with_classes("group-hover:bg-primary"));
135
+ let css = compile_sfc_scoped(&sfc_with_classes("group-hover:bg-primary")).unwrap();
130
136
  // Ancestor descendant-combinator: `.group:hover .group-hover\:bg-primary`.
131
137
  assert!(
132
138
  css.contains(".group:hover .group-hover\\:bg-primary"),
@@ -143,14 +149,14 @@ fn group_focus_variants_emit_each_state() {
143
149
  ("group-active:bg-primary", ".group:active "),
144
150
  ("group-disabled:bg-primary", ".group:disabled "),
145
151
  ] {
146
- let css = compile_sfc_scoped(&sfc_with_classes(cls));
152
+ let css = compile_sfc_scoped(&sfc_with_classes(cls)).unwrap();
147
153
  assert!(css.contains(sel), "{cls} → `{sel}` prefix: {css}");
148
154
  }
149
155
  }
150
156
 
151
157
  #[test]
152
158
  fn peer_checked_emits_sibling_state_selector() {
153
- let css = compile_sfc_scoped(&sfc_with_classes("peer-checked:bg-primary"));
159
+ let css = compile_sfc_scoped(&sfc_with_classes("peer-checked:bg-primary")).unwrap();
154
160
  // Subsequent-sibling combinator: `.peer:checked ~ .peer-checked\:bg-primary`.
155
161
  assert!(
156
162
  css.contains(".peer:checked ~ .peer-checked\\:bg-primary"),
@@ -167,14 +173,14 @@ fn peer_state_variants_emit_each_state() {
167
173
  ("peer-focus-visible:bg-primary", ".peer:focus-visible ~ "),
168
174
  ("peer-disabled:bg-primary", ".peer:disabled ~ "),
169
175
  ] {
170
- let css = compile_sfc_scoped(&sfc_with_classes(cls));
176
+ let css = compile_sfc_scoped(&sfc_with_classes(cls)).unwrap();
171
177
  assert!(css.contains(sel), "{cls} → `{sel}` prefix: {css}");
172
178
  }
173
179
  }
174
180
 
175
181
  #[test]
176
182
  fn bare_group_and_peer_markers_emit_empty_rules() {
177
- let css = compile_sfc_scoped(&sfc_with_classes("group peer"));
183
+ let css = compile_sfc_scoped(&sfc_with_classes("group peer")).unwrap();
178
184
  // Markers survive as empty-body rules so the relational selectors resolve.
179
185
  assert!(css.contains(".group {"), "bare `group` marker kept: {css}");
180
186
  assert!(css.contains(".peer {"), "bare `peer` marker kept: {css}");
@@ -183,7 +189,7 @@ fn bare_group_and_peer_markers_emit_empty_rules() {
183
189
  #[test]
184
190
  fn group_peer_stack_with_responsive() {
185
191
  // `md:group-hover:bg-primary` — breakpoint wraps the relational rule.
186
- let css = compile_sfc_scoped(&sfc_with_classes("md:group-hover:bg-primary"));
192
+ let css = compile_sfc_scoped(&sfc_with_classes("md:group-hover:bg-primary")).unwrap();
187
193
  assert!(css.contains("@media (min-width:"), "breakpoint wrapper: {css}");
188
194
  assert!(css.contains(".group:hover "), "relational selector inside media: {css}");
189
195
  }
@@ -197,7 +203,7 @@ fn theme_override_registers_token() {
197
203
  "meta":{"name":"X"},
198
204
  "template":[{"kind":"element","tag":"div","attrs":[
199
205
  {"kind":"static","name":"class","value":"bg-primary"}],"children":[]}]}"#;
200
- let css = compile_sfc_scoped(&ast(json));
206
+ let css = compile_sfc_scoped(&ast(json)).unwrap();
201
207
  // The override value is registered as a :host token...
202
208
  assert!(css.contains("--color-primary: oklch(0.7 0.2 30)"), "@theme override registered: {css}");
203
209
  // ...and the utility references it.
@@ -208,7 +214,7 @@ fn theme_override_registers_token() {
208
214
 
209
215
  #[test]
210
216
  fn default_aihu_brand_tokens_present() {
211
- let css = compile_sfc_scoped(&sfc_with_classes("bg-accent"));
217
+ let css = compile_sfc_scoped(&sfc_with_classes("bg-accent")).unwrap();
212
218
  // Default accent is the aihu terracotta.
213
219
  assert!(css.contains("--color-accent: #c8543a"), "baked aihu brand default present: {css}");
214
220
  }
@@ -217,7 +223,7 @@ fn default_aihu_brand_tokens_present() {
217
223
 
218
224
  #[test]
219
225
  fn aria_keyword_variant_emits_true_attr_selector() {
220
- let css = compile_sfc_scoped(&sfc_with_classes("aria-checked:bg-accent"));
226
+ let css = compile_sfc_scoped(&sfc_with_classes("aria-checked:bg-accent")).unwrap();
221
227
  assert!(
222
228
  css.contains(r#"[aria-checked="true"]"#),
223
229
  "aria-checked: → [aria-checked=\"true\"] selector: {css}"
@@ -227,7 +233,7 @@ fn aria_keyword_variant_emits_true_attr_selector() {
227
233
 
228
234
  #[test]
229
235
  fn aria_expanded_variant() {
230
- let css = compile_sfc_scoped(&sfc_with_classes("aria-expanded:underline"));
236
+ let css = compile_sfc_scoped(&sfc_with_classes("aria-expanded:underline")).unwrap();
231
237
  assert!(
232
238
  css.contains(r#"[aria-expanded="true"]"#),
233
239
  "aria-expanded: → [aria-expanded=\"true\"]: {css}"
@@ -242,14 +248,14 @@ fn aria_disabled_selected_pressed_variants() {
242
248
  ("aria-selected:bg-primary", r#"[aria-selected="true"]"#),
243
249
  ("aria-pressed:bg-accent", r#"[aria-pressed="true"]"#),
244
250
  ] {
245
- let css = compile_sfc_scoped(&sfc_with_classes(cls));
251
+ let css = compile_sfc_scoped(&sfc_with_classes(cls)).unwrap();
246
252
  assert!(css.contains(attr), "{cls} → {attr}: {css}");
247
253
  }
248
254
  }
249
255
 
250
256
  #[test]
251
257
  fn aria_arbitrary_value_variant() {
252
- let css = compile_sfc_scoped(&sfc_with_classes("aria-[expanded=false]:underline"));
258
+ let css = compile_sfc_scoped(&sfc_with_classes("aria-[expanded=false]:underline")).unwrap();
253
259
  assert!(
254
260
  css.contains(r#"[aria-expanded="false"]"#),
255
261
  "aria-[expanded=false]: → [aria-expanded=\"false\"]: {css}"
@@ -258,7 +264,7 @@ fn aria_arbitrary_value_variant() {
258
264
 
259
265
  #[test]
260
266
  fn data_state_variant_emits_attr_selector() {
261
- let css = compile_sfc_scoped(&sfc_with_classes("data-[state=open]:bg-accent"));
267
+ let css = compile_sfc_scoped(&sfc_with_classes("data-[state=open]:bg-accent")).unwrap();
262
268
  assert!(
263
269
  css.contains(r#"[data-state="open"]"#),
264
270
  "data-[state=open]: → [data-state=\"open\"] selector: {css}"
@@ -269,7 +275,7 @@ fn data_state_variant_emits_attr_selector() {
269
275
  #[test]
270
276
  fn data_keyword_variant_is_presence_selector() {
271
277
  // Bare `data-active:` is a presence selector (no implicit ="true").
272
- let css = compile_sfc_scoped(&sfc_with_classes("data-active:underline"));
278
+ let css = compile_sfc_scoped(&sfc_with_classes("data-active:underline")).unwrap();
273
279
  assert!(
274
280
  css.contains("[data-active]"),
275
281
  "data-active: → [data-active] presence selector: {css}"
@@ -281,7 +287,7 @@ fn data_keyword_variant_is_presence_selector() {
281
287
 
282
288
  #[test]
283
289
  fn container_marker_sets_container_type() {
284
- let css = compile_sfc_scoped(&sfc_with_classes("@container"));
290
+ let css = compile_sfc_scoped(&sfc_with_classes("@container")).unwrap();
285
291
  assert!(
286
292
  css.contains("container-type: inline-size"),
287
293
  "@container marker → container-type: inline-size: {css}"
@@ -290,7 +296,7 @@ fn container_marker_sets_container_type() {
290
296
 
291
297
  #[test]
292
298
  fn named_container_marker_sets_type_and_name() {
293
- let css = compile_sfc_scoped(&sfc_with_classes("@container/sidebar"));
299
+ let css = compile_sfc_scoped(&sfc_with_classes("@container/sidebar")).unwrap();
294
300
  assert!(css.contains("container-type: inline-size"), "{css}");
295
301
  assert!(
296
302
  css.contains("container-name: sidebar"),
@@ -300,7 +306,7 @@ fn named_container_marker_sets_type_and_name() {
300
306
 
301
307
  #[test]
302
308
  fn container_md_wraps_rule_in_container_at_rule() {
303
- let css = compile_sfc_scoped(&sfc_with_classes("@md:flex"));
309
+ let css = compile_sfc_scoped(&sfc_with_classes("@md:flex")).unwrap();
304
310
  assert!(
305
311
  css.contains("@container (min-width:"),
306
312
  "@md: → @container (min-width: ...): {css}"
@@ -314,9 +320,9 @@ fn container_md_wraps_rule_in_container_at_rule() {
314
320
 
315
321
  #[test]
316
322
  fn container_sm_lg_scale() {
317
- let sm = compile_sfc_scoped(&sfc_with_classes("@sm:block"));
323
+ let sm = compile_sfc_scoped(&sfc_with_classes("@sm:block")).unwrap();
318
324
  assert!(sm.contains("@container (min-width: 24rem)"), "@sm = 24rem: {sm}");
319
- let lg = compile_sfc_scoped(&sfc_with_classes("@lg:hidden"));
325
+ let lg = compile_sfc_scoped(&sfc_with_classes("@lg:hidden")).unwrap();
320
326
  assert!(lg.contains("@container (min-width: 32rem)"), "@lg = 32rem: {lg}");
321
327
  }
322
328
 
@@ -328,7 +334,7 @@ fn container_parent_and_child_pair() {
328
334
  {"kind":"static","name":"class","value":"@container"}],
329
335
  "children":[{"kind":"element","tag":"div","attrs":[
330
336
  {"kind":"static","name":"class","value":"@md:flex"}],"children":[]}]}]}"#;
331
- let css = compile_sfc_scoped(&ast(json));
337
+ let css = compile_sfc_scoped(&ast(json)).unwrap();
332
338
  assert!(css.contains("container-type: inline-size"), "{css}");
333
339
  assert!(css.contains("@container (min-width: 28rem)"), "{css}");
334
340
  }
@@ -340,7 +346,7 @@ fn aria_data_without_known_base_emits_nothing() {
340
346
  // Unknown base utility behind an aria/data variant must not emit a rule.
341
347
  let css = compile_sfc_scoped(&sfc_with_classes(
342
348
  "aria-checked:notathing data-[state=open]:alsonope @md:bogus",
343
- ));
349
+ )).unwrap();
344
350
  assert!(
345
351
  !css.contains("[aria-checked"),
346
352
  "no aria selector for unknown base: {css}"
@@ -354,3 +360,56 @@ fn aria_data_without_known_base_emits_nothing() {
354
360
  "no container at-rule for unknown base: {css}"
355
361
  );
356
362
  }
363
+
364
+ // ── R-RESULT: emit returns Result; an induced emit error propagates ──────────
365
+
366
+ #[test]
367
+ fn malformed_theme_block_is_a_compile_error() {
368
+ // An `@theme` opener with no `{` body is malformed; the emit path now
369
+ // hard-errors (CompileError) instead of silently keeping the broken text.
370
+ let json = r#"{"tag":"X","astVersion":1,
371
+ "style":{"content":"@theme --color-primary: red;","scope":"scoped"},
372
+ "meta":{"name":"X"},"template":null}"#;
373
+ let err = compile_sfc_scoped(&ast(json)).unwrap_err();
374
+ assert!(
375
+ matches!(err, CompileError::MalformedTheme { .. }),
376
+ "expected MalformedTheme, got {err:?}"
377
+ );
378
+ assert!(
379
+ err.to_string().contains("malformed @theme"),
380
+ "actionable message: {err}"
381
+ );
382
+ }
383
+
384
+ #[test]
385
+ fn well_formed_theme_block_still_succeeds() {
386
+ // Success path is byte-identical to before the Result conversion.
387
+ let json = r#"{"tag":"X","astVersion":1,
388
+ "style":{"content":"@theme { --color-primary: red; }","scope":"scoped"},
389
+ "meta":{"name":"X"},
390
+ "template":[{"kind":"element","tag":"div","attrs":[
391
+ {"kind":"static","name":"class","value":"bg-primary"}],"children":[]}]}"#;
392
+ let css = compile_sfc_scoped(&ast(json)).unwrap();
393
+ assert!(css.contains("--color-primary: red"), "{css}");
394
+ assert!(css.contains("background-color: var(--color-primary)"), "{css}");
395
+ assert!(!css.contains("@theme"), "directive consumed, not emitted: {css}");
396
+ }
397
+
398
+ // ── Issue #280: Preflight border-style reset ─────────────────────────────────
399
+
400
+ #[test]
401
+ fn preflight_border_reset_precedes_utilities() {
402
+ // `.unwrap()` added in the PR-1 merge: compile_sfc_scoped now returns Result.
403
+ let css = compile_sfc_scoped(&sfc_with_classes("border")).unwrap();
404
+ // Tailwind v4 Preflight: borders default to solid + zero width.
405
+ assert!(
406
+ css.contains("*, ::before, ::after { border-style: solid; border-width: 0; }"),
407
+ "preflight border reset present: {css}"
408
+ );
409
+ // The `.border` utility still wins by specificity.
410
+ assert!(css.contains(".border { border-width: 1px; }"), "border utility emitted: {css}");
411
+ // Preflight must come before the utility rule so the utility cascades over it.
412
+ let pre = css.find("border-style: solid").unwrap();
413
+ let util = css.find(".border {").unwrap();
414
+ assert!(pre < util, "preflight precedes utility rules: {css}");
415
+ }
@@ -0,0 +1,274 @@
1
+ //! Tailwind-v4 parity round: coverage for the utility families, the
2
+ //! `(--var)` shorthand, the arbitrary border-color typing fix, palette-token
3
+ //! injection, and the pseudo-class/element variants added to render a
4
+ //! full Tailwind-authored landing page on the engine.
5
+
6
+ use aihu_css_core::{compile_classes, compile_sfc_scoped, parse_ast};
7
+
8
+ fn css(classes: &[&str]) -> String {
9
+ compile_classes(&classes.iter().map(|s| s.to_string()).collect::<Vec<_>>())
10
+ }
11
+
12
+ fn one(class: &str) -> String {
13
+ compile_classes(&[class.to_string()])
14
+ }
15
+
16
+ fn sfc(classes: &str) -> aihu_css_core::SfcAst {
17
+ let json = format!(
18
+ r#"{{"tag":"X","astVersion":1,"style":null,"meta":{{"name":"X"}},
19
+ "template":[{{"kind":"element","tag":"div","attrs":[
20
+ {{"kind":"static","name":"class","value":"{classes}"}}
21
+ ],"children":[]}}]}}"#
22
+ );
23
+ parse_ast(&json).unwrap()
24
+ }
25
+
26
+ // ── The root-cause fix: arbitrary border value typed as color, not width ─────
27
+
28
+ #[test]
29
+ fn arbitrary_border_value_is_color_typed_by_value() {
30
+ // A color-looking value → border-color (previously always border-width).
31
+ assert_eq!(
32
+ one("border-[var(--border)]"),
33
+ ".border-[var(--border)] { border-color: var(--border); }\n"
34
+ );
35
+ assert_eq!(
36
+ one("border-[#abc]"),
37
+ ".border-[#abc] { border-color: #abc; }\n"
38
+ );
39
+ // A length value still maps to width.
40
+ assert_eq!(
41
+ one("border-[2px]"),
42
+ ".border-[2px] { border-width: 2px; }\n"
43
+ );
44
+ // Explicit data-type hints override the heuristic.
45
+ assert_eq!(
46
+ one("outline-[color:var(--x)]"),
47
+ ".outline-[color:var(--x)] { outline-color: var(--x); }\n"
48
+ );
49
+ }
50
+
51
+ // ── The CSS-variable shorthand `prefix-(--token)` ───────────────────────────
52
+
53
+ #[test]
54
+ fn var_shorthand_keeps_prefix_property_typing() {
55
+ assert_eq!(
56
+ one("bg-(--background)"),
57
+ ".bg-(--background) { background-color: var(--background); }\n"
58
+ );
59
+ assert_eq!(
60
+ one("text-(--muted-fg)"),
61
+ ".text-(--muted-fg) { color: var(--muted-fg); }\n"
62
+ );
63
+ // border keeps COLOR typing (not width) through the shorthand.
64
+ assert_eq!(
65
+ one("border-(--border)"),
66
+ ".border-(--border) { border-color: var(--border); }\n"
67
+ );
68
+ }
69
+
70
+ #[test]
71
+ fn var_shorthand_opacity_modifier_color_mixes() {
72
+ assert_eq!(
73
+ one("bg-(--background)/90"),
74
+ ".bg-(--background)/90 { background-color: color-mix(in oklab, var(--background) 90%, transparent); }\n"
75
+ );
76
+ }
77
+
78
+ // ── Sizing / aspect / order / fractions ─────────────────────────────────────
79
+
80
+ #[test]
81
+ fn size_emits_width_and_height() {
82
+ assert_eq!(one("size-16"), ".size-16 { width: 4rem; height: 4rem; }\n");
83
+ assert_eq!(
84
+ one("size-full"),
85
+ ".size-full { width: 100%; height: 100%; }\n"
86
+ );
87
+ }
88
+
89
+ #[test]
90
+ fn aspect_ratio_keywords_and_bare_ratio() {
91
+ assert_eq!(
92
+ one("aspect-square"),
93
+ ".aspect-square { aspect-ratio: 1 / 1; }\n"
94
+ );
95
+ assert_eq!(
96
+ one("aspect-video"),
97
+ ".aspect-video { aspect-ratio: 16 / 9; }\n"
98
+ );
99
+ assert_eq!(
100
+ one("aspect-1108/632"),
101
+ ".aspect-1108/632 { aspect-ratio: 1108 / 632; }\n"
102
+ );
103
+ }
104
+
105
+ #[test]
106
+ fn fractional_position_and_negative_translate() {
107
+ assert_eq!(one("left-1/2"), ".left-1/2 { left: 50%; }\n");
108
+ assert_eq!(one("top-1/2"), ".top-1/2 { top: 50%; }\n");
109
+ assert_eq!(
110
+ one("-translate-x-1/2"),
111
+ ".-translate-x-1/2 { transform: translateX(-50%); }\n"
112
+ );
113
+ }
114
+
115
+ #[test]
116
+ fn negative_margin_and_z_index() {
117
+ assert_eq!(one("-ml-4"), ".-ml-4 { margin-left: -1rem; }\n");
118
+ assert_eq!(one("-z-10"), ".-z-10 { z-index: -10; }\n");
119
+ assert_eq!(one("order-2"), ".order-2 { order: 2; }\n");
120
+ }
121
+
122
+ // ── Gradients ───────────────────────────────────────────────────────────────
123
+
124
+ #[test]
125
+ fn linear_gradient_direction_and_stops() {
126
+ assert_eq!(
127
+ one("bg-linear-to-r"),
128
+ ".bg-linear-to-r { background-image: linear-gradient(to right, var(--tw-gradient-stops)); }\n"
129
+ );
130
+ assert!(one("from-amber-200").contains("--tw-gradient-from: var(--color-amber-200)"));
131
+ assert!(one("to-amber-600").contains("--tw-gradient-to: var(--color-amber-600)"));
132
+ assert!(one("via-(--accent)").contains("var(--accent)"));
133
+ }
134
+
135
+ // ── Effects / typography long-tail ──────────────────────────────────────────
136
+
137
+ #[test]
138
+ fn blur_mask_shadow_outline_scales() {
139
+ assert_eq!(one("blur-3xl"), ".blur-3xl { filter: blur(64px); }\n");
140
+ assert!(one("backdrop-blur-sm").contains("backdrop-filter: blur(8px)"));
141
+ assert!(one("mask-[radial-gradient(circle,white,transparent)]")
142
+ .contains("mask-image: radial-gradient(circle,white,transparent)"));
143
+ assert!(one("shadow-xl").contains("box-shadow:"));
144
+ assert_eq!(
145
+ one("outline-2"),
146
+ ".outline-2 { outline-style: solid; outline-width: 2px; }\n"
147
+ );
148
+ assert_eq!(
149
+ one("-outline-offset-1"),
150
+ ".-outline-offset-1 { outline-offset: -1px; }\n"
151
+ );
152
+ }
153
+
154
+ #[test]
155
+ fn type_scale_slash_line_height_and_family() {
156
+ assert_eq!(
157
+ one("text-7xl"),
158
+ ".text-7xl { font-size: 4.5rem; line-height: 1; }\n"
159
+ );
160
+ assert_eq!(
161
+ one("text-sm/6"),
162
+ ".text-sm/6 { font-size: 0.875rem; line-height: 1.5rem; }\n"
163
+ );
164
+ assert_eq!(
165
+ one("font-serif"),
166
+ ".font-serif { font-family: var(--font-serif); }\n"
167
+ );
168
+ assert_eq!(one("text-pretty"), ".text-pretty { text-wrap: pretty; }\n");
169
+ }
170
+
171
+ #[test]
172
+ fn long_tail_fixed_utilities() {
173
+ assert_eq!(one("isolate"), ".isolate { isolation: isolate; }\n");
174
+ assert_eq!(
175
+ one("cursor-pointer"),
176
+ ".cursor-pointer { cursor: pointer; }\n"
177
+ );
178
+ assert_eq!(
179
+ one("rounded-3xl"),
180
+ ".rounded-3xl { border-radius: 1.5rem; }\n"
181
+ );
182
+ assert_eq!(one("list-none"), ".list-none { list-style-type: none; }\n");
183
+ assert_eq!(one("shrink-0"), ".shrink-0 { flex-shrink: 0; }\n");
184
+ assert_eq!(
185
+ one("self-start"),
186
+ ".self-start { align-self: flex-start; }\n"
187
+ );
188
+ assert!(one("sr-only").contains("position: absolute"));
189
+ }
190
+
191
+ // ── Palette injection: referenced palette tokens resolve at :host ────────────
192
+
193
+ #[test]
194
+ fn scoped_injects_used_palette_tokens() {
195
+ let out = compile_sfc_scoped(&sfc("bg-amber-500 text-stone-300")).unwrap();
196
+ // The utility refs the palette token…
197
+ assert!(out.contains("background-color: var(--color-amber-500)"));
198
+ // …and the scoped emitter registers its oklch value at :host.
199
+ assert!(out.contains("--color-amber-500: oklch("));
200
+ assert!(out.contains("--color-stone-300: oklch("));
201
+ // Only USED palette tokens are injected — an unrelated family is absent.
202
+ assert!(!out.contains("--color-rose-500"));
203
+ }
204
+
205
+ // ── Variants: pseudo-classes, pseudo-elements, relational open ──────────────
206
+
207
+ #[test]
208
+ fn pseudo_class_variants_first_last_open() {
209
+ let out = compile_sfc_scoped(&sfc("first:mt-0 last:mb-0 open:block")).unwrap();
210
+ assert!(out.contains(":first-child"));
211
+ assert!(out.contains(":last-child"));
212
+ assert!(out.contains(":open"));
213
+ }
214
+
215
+ #[test]
216
+ fn pseudo_element_variants_marker_and_placeholder() {
217
+ let out = compile_sfc_scoped(&sfc("marker:text-primary placeholder:text-stone-400")).unwrap();
218
+ assert!(out.contains("::marker"));
219
+ assert!(out.contains("::placeholder"));
220
+ }
221
+
222
+ #[test]
223
+ fn group_open_relational_variant() {
224
+ let out = compile_sfc_scoped(&sfc("group-open:rotate-180")).unwrap();
225
+ assert!(out.contains(".group:open"));
226
+ }
227
+
228
+ // ── A representative slice of the real landing compiles end to end ───────────
229
+
230
+ #[test]
231
+ fn landing_utility_slice_all_emit() {
232
+ // Base (non-variant) utilities only — flat `compile_classes` does not emit
233
+ // variant-prefixed classes (those are exercised by the variant tests above).
234
+ let slice = [
235
+ "relative",
236
+ "isolate",
237
+ "overflow-hidden",
238
+ "bg-(--background)",
239
+ "size-full",
240
+ "mask-[radial-gradient(100%_100%_at_top_right,white,transparent)]",
241
+ "stroke-(--border)",
242
+ "fill-(--muted)",
243
+ "aspect-1108/632",
244
+ "bg-linear-to-r",
245
+ "from-amber-200",
246
+ "to-amber-600",
247
+ "opacity-20",
248
+ "max-w-7xl",
249
+ "px-6",
250
+ "text-7xl",
251
+ "font-serif",
252
+ "text-pretty",
253
+ "text-(--foreground)",
254
+ "text-lg/8",
255
+ "rounded-md",
256
+ "bg-(--primary)",
257
+ "text-(--primary-fg)",
258
+ "shadow-xs",
259
+ "size-3",
260
+ "rounded-full",
261
+ "bg-red-400",
262
+ "ring-1",
263
+ "ring-gray-900/10",
264
+ ];
265
+ let out = css(&slice);
266
+ // Every class in the slice must produce a rule (no silently-dropped utility).
267
+ for class in slice {
268
+ let sel = format!(".{class} {{");
269
+ assert!(
270
+ out.contains(&sel),
271
+ "landing utility `{class}` produced no rule"
272
+ );
273
+ }
274
+ }