@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
@@ -0,0 +1,61 @@
1
+ //! Binary-level error propagation (R-RESULT): an induced emit error must exit
2
+ //! the `aihu-css-compile` binary non-zero AND print the message to stderr.
3
+ //! Cargo sets `CARGO_BIN_EXE_aihu-css-compile` for this integration test.
4
+
5
+ use std::io::Write;
6
+ use std::process::{Command, Stdio};
7
+
8
+ fn run_ast_mode(ast_json: &str) -> std::process::Output {
9
+ let bin = env!("CARGO_BIN_EXE_aihu-css-compile");
10
+ let mut child = Command::new(bin)
11
+ .arg("--ast-json")
12
+ .stdin(Stdio::piped())
13
+ .stdout(Stdio::piped())
14
+ .stderr(Stdio::piped())
15
+ .spawn()
16
+ .expect("spawn aihu-css-compile");
17
+ child
18
+ .stdin
19
+ .as_mut()
20
+ .expect("stdin")
21
+ .write_all(ast_json.as_bytes())
22
+ .expect("write stdin");
23
+ child.wait_with_output().expect("wait")
24
+ }
25
+
26
+ #[test]
27
+ fn malformed_theme_exits_nonzero_with_stderr_message() {
28
+ // `@theme` with no `{` body → CompileError::MalformedTheme.
29
+ let json = r#"{"tag":"X","astVersion":1,
30
+ "style":{"content":"@theme --color-primary: red;","scope":"scoped"},
31
+ "meta":{"name":"X"},"template":null}"#;
32
+ let out = run_ast_mode(json);
33
+ assert!(
34
+ !out.status.success(),
35
+ "binary must exit non-zero on emit error; status={:?}",
36
+ out.status
37
+ );
38
+ let stderr = String::from_utf8_lossy(&out.stderr);
39
+ assert!(
40
+ stderr.contains("aihu-css-compile:") && stderr.contains("malformed @theme"),
41
+ "stderr must carry the actionable message, got: {stderr:?}"
42
+ );
43
+ assert!(
44
+ out.stdout.is_empty(),
45
+ "no CSS should be written on error; stdout={:?}",
46
+ String::from_utf8_lossy(&out.stdout)
47
+ );
48
+ }
49
+
50
+ #[test]
51
+ fn well_formed_input_exits_zero_and_emits_css() {
52
+ let json = r#"{"tag":"X","astVersion":1,
53
+ "style":{"content":"@theme { --color-primary: red; }","scope":"scoped"},
54
+ "meta":{"name":"X"},
55
+ "template":[{"kind":"element","tag":"div","attrs":[
56
+ {"kind":"static","name":"class","value":"bg-primary"}],"children":[]}]}"#;
57
+ let out = run_ast_mode(json);
58
+ assert!(out.status.success(), "well-formed input exits zero");
59
+ let css = String::from_utf8_lossy(&out.stdout);
60
+ assert!(css.contains("background-color: var(--color-primary)"), "{css}");
61
+ }
@@ -20,11 +20,11 @@ fn hit_returns_identical_output_and_skips_recompile() {
20
20
  let mut cache = CssCache::new();
21
21
  let ast = sfc("bg-primary p-4");
22
22
 
23
- let first = cache.compile(&ast, 1);
23
+ let first = cache.compile(&ast, 1).unwrap();
24
24
  assert_eq!(cache.recompiles(), 1, "first compile is a miss");
25
25
  assert_eq!(cache.hits(), 0);
26
26
 
27
- let second = cache.compile(&ast, 1);
27
+ let second = cache.compile(&ast, 1).unwrap();
28
28
  assert_eq!(second, first, "cache hit returns byte-identical output");
29
29
  assert_eq!(cache.recompiles(), 1, "second compile must NOT recompile");
30
30
  assert_eq!(cache.hits(), 1, "second compile is a hit");
@@ -33,8 +33,8 @@ fn hit_returns_identical_output_and_skips_recompile() {
33
33
  #[test]
34
34
  fn ast_change_invalidates_entry() {
35
35
  let mut cache = CssCache::new();
36
- cache.compile(&sfc("p-4"), 1);
37
- cache.compile(&sfc("p-8"), 1); // different class → different hash
36
+ cache.compile(&sfc("p-4"), 1).unwrap();
37
+ cache.compile(&sfc("p-8"), 1).unwrap(); // different class → different hash
38
38
  assert_eq!(cache.recompiles(), 2, "a changed AST recompiles");
39
39
  assert_eq!(cache.hits(), 0);
40
40
  }
@@ -43,8 +43,8 @@ fn ast_change_invalidates_entry() {
43
43
  fn theme_version_change_invalidates_entry() {
44
44
  let mut cache = CssCache::new();
45
45
  let ast = sfc("bg-primary");
46
- cache.compile(&ast, 1);
47
- cache.compile(&ast, 2); // theme bumped → invalidate
46
+ cache.compile(&ast, 1).unwrap();
47
+ cache.compile(&ast, 2).unwrap(); // theme bumped → invalidate
48
48
  assert_eq!(cache.recompiles(), 2, "a theme-version bump recompiles");
49
49
  }
50
50
 
@@ -53,12 +53,12 @@ fn cache_hit_is_well_under_30ms() {
53
53
  let mut cache = CssCache::new();
54
54
  // Warm the cache.
55
55
  let ast = sfc("bg-primary p-4 rounded-lg hover:bg-accent md:p-8 host:text-primary");
56
- let _ = cache.compile(&ast, 1);
56
+ let _ = cache.compile(&ast, 1).unwrap();
57
57
 
58
58
  // Time 1000 cache hits; each must be far below the 30 ms per-SFC bar.
59
59
  let start = Instant::now();
60
60
  for _ in 0..1000 {
61
- let _ = cache.compile(&ast, 1);
61
+ let _ = cache.compile(&ast, 1).unwrap();
62
62
  }
63
63
  let elapsed = start.elapsed();
64
64
  let per_hit = elapsed / 1000;
@@ -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,38 +96,104 @@ 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
  }
124
130
 
131
+ // ── Round 2: group / peer relational variants ────────────────────────────────
132
+
133
+ #[test]
134
+ fn group_hover_emits_ancestor_state_selector() {
135
+ let css = compile_sfc_scoped(&sfc_with_classes("group-hover:bg-primary")).unwrap();
136
+ // Ancestor descendant-combinator: `.group:hover .group-hover\:bg-primary`.
137
+ assert!(
138
+ css.contains(".group:hover .group-hover\\:bg-primary"),
139
+ "group-hover: → `.group:hover <base>` ancestor selector: {css}"
140
+ );
141
+ assert!(css.contains("background-color: var(--color-primary)"));
142
+ }
143
+
144
+ #[test]
145
+ fn group_focus_variants_emit_each_state() {
146
+ for (cls, sel) in [
147
+ ("group-focus:bg-primary", ".group:focus "),
148
+ ("group-focus-visible:bg-primary", ".group:focus-visible "),
149
+ ("group-active:bg-primary", ".group:active "),
150
+ ("group-disabled:bg-primary", ".group:disabled "),
151
+ ] {
152
+ let css = compile_sfc_scoped(&sfc_with_classes(cls)).unwrap();
153
+ assert!(css.contains(sel), "{cls} → `{sel}` prefix: {css}");
154
+ }
155
+ }
156
+
157
+ #[test]
158
+ fn peer_checked_emits_sibling_state_selector() {
159
+ let css = compile_sfc_scoped(&sfc_with_classes("peer-checked:bg-primary")).unwrap();
160
+ // Subsequent-sibling combinator: `.peer:checked ~ .peer-checked\:bg-primary`.
161
+ assert!(
162
+ css.contains(".peer:checked ~ .peer-checked\\:bg-primary"),
163
+ "peer-checked: → `.peer:checked ~ <base>` sibling selector: {css}"
164
+ );
165
+ assert!(css.contains("background-color: var(--color-primary)"));
166
+ }
167
+
168
+ #[test]
169
+ fn peer_state_variants_emit_each_state() {
170
+ for (cls, sel) in [
171
+ ("peer-hover:bg-primary", ".peer:hover ~ "),
172
+ ("peer-focus:bg-primary", ".peer:focus ~ "),
173
+ ("peer-focus-visible:bg-primary", ".peer:focus-visible ~ "),
174
+ ("peer-disabled:bg-primary", ".peer:disabled ~ "),
175
+ ] {
176
+ let css = compile_sfc_scoped(&sfc_with_classes(cls)).unwrap();
177
+ assert!(css.contains(sel), "{cls} → `{sel}` prefix: {css}");
178
+ }
179
+ }
180
+
181
+ #[test]
182
+ fn bare_group_and_peer_markers_emit_empty_rules() {
183
+ let css = compile_sfc_scoped(&sfc_with_classes("group peer")).unwrap();
184
+ // Markers survive as empty-body rules so the relational selectors resolve.
185
+ assert!(css.contains(".group {"), "bare `group` marker kept: {css}");
186
+ assert!(css.contains(".peer {"), "bare `peer` marker kept: {css}");
187
+ }
188
+
189
+ #[test]
190
+ fn group_peer_stack_with_responsive() {
191
+ // `md:group-hover:bg-primary` — breakpoint wraps the relational rule.
192
+ let css = compile_sfc_scoped(&sfc_with_classes("md:group-hover:bg-primary")).unwrap();
193
+ assert!(css.contains("@media (min-width:"), "breakpoint wrapper: {css}");
194
+ assert!(css.contains(".group:hover "), "relational selector inside media: {css}");
195
+ }
196
+
125
197
  // ── Task 7: @theme directive ─────────────────────────────────────────────────
126
198
 
127
199
  #[test]
@@ -131,7 +203,7 @@ fn theme_override_registers_token() {
131
203
  "meta":{"name":"X"},
132
204
  "template":[{"kind":"element","tag":"div","attrs":[
133
205
  {"kind":"static","name":"class","value":"bg-primary"}],"children":[]}]}"#;
134
- let css = compile_sfc_scoped(&ast(json));
206
+ let css = compile_sfc_scoped(&ast(json)).unwrap();
135
207
  // The override value is registered as a :host token...
136
208
  assert!(css.contains("--color-primary: oklch(0.7 0.2 30)"), "@theme override registered: {css}");
137
209
  // ...and the utility references it.
@@ -142,7 +214,202 @@ fn theme_override_registers_token() {
142
214
 
143
215
  #[test]
144
216
  fn default_aihu_brand_tokens_present() {
145
- let css = compile_sfc_scoped(&sfc_with_classes("bg-accent"));
217
+ let css = compile_sfc_scoped(&sfc_with_classes("bg-accent")).unwrap();
146
218
  // Default accent is the aihu terracotta.
147
219
  assert!(css.contains("--color-accent: #c8543a"), "baked aihu brand default present: {css}");
148
220
  }
221
+
222
+ // ── Round 2: aria-* / data-* attribute variants ─────────────────────────────
223
+
224
+ #[test]
225
+ fn aria_keyword_variant_emits_true_attr_selector() {
226
+ let css = compile_sfc_scoped(&sfc_with_classes("aria-checked:bg-accent")).unwrap();
227
+ assert!(
228
+ css.contains(r#"[aria-checked="true"]"#),
229
+ "aria-checked: → [aria-checked=\"true\"] selector: {css}"
230
+ );
231
+ assert!(css.contains("background-color: var(--color-accent)"));
232
+ }
233
+
234
+ #[test]
235
+ fn aria_expanded_variant() {
236
+ let css = compile_sfc_scoped(&sfc_with_classes("aria-expanded:underline")).unwrap();
237
+ assert!(
238
+ css.contains(r#"[aria-expanded="true"]"#),
239
+ "aria-expanded: → [aria-expanded=\"true\"]: {css}"
240
+ );
241
+ assert!(css.contains("text-decoration-line: underline"));
242
+ }
243
+
244
+ #[test]
245
+ fn aria_disabled_selected_pressed_variants() {
246
+ for (cls, attr) in [
247
+ ("aria-disabled:opacity-50", r#"[aria-disabled="true"]"#),
248
+ ("aria-selected:bg-primary", r#"[aria-selected="true"]"#),
249
+ ("aria-pressed:bg-accent", r#"[aria-pressed="true"]"#),
250
+ ] {
251
+ let css = compile_sfc_scoped(&sfc_with_classes(cls)).unwrap();
252
+ assert!(css.contains(attr), "{cls} → {attr}: {css}");
253
+ }
254
+ }
255
+
256
+ #[test]
257
+ fn aria_arbitrary_value_variant() {
258
+ let css = compile_sfc_scoped(&sfc_with_classes("aria-[expanded=false]:underline")).unwrap();
259
+ assert!(
260
+ css.contains(r#"[aria-expanded="false"]"#),
261
+ "aria-[expanded=false]: → [aria-expanded=\"false\"]: {css}"
262
+ );
263
+ }
264
+
265
+ #[test]
266
+ fn data_state_variant_emits_attr_selector() {
267
+ let css = compile_sfc_scoped(&sfc_with_classes("data-[state=open]:bg-accent")).unwrap();
268
+ assert!(
269
+ css.contains(r#"[data-state="open"]"#),
270
+ "data-[state=open]: → [data-state=\"open\"] selector: {css}"
271
+ );
272
+ assert!(css.contains("background-color: var(--color-accent)"));
273
+ }
274
+
275
+ #[test]
276
+ fn data_keyword_variant_is_presence_selector() {
277
+ // Bare `data-active:` is a presence selector (no implicit ="true").
278
+ let css = compile_sfc_scoped(&sfc_with_classes("data-active:underline")).unwrap();
279
+ assert!(
280
+ css.contains("[data-active]"),
281
+ "data-active: → [data-active] presence selector: {css}"
282
+ );
283
+ assert!(!css.contains(r#"[data-active="true"]"#));
284
+ }
285
+
286
+ // ── Round 2: container queries (@container) ──────────────────────────────────
287
+
288
+ #[test]
289
+ fn container_marker_sets_container_type() {
290
+ let css = compile_sfc_scoped(&sfc_with_classes("@container")).unwrap();
291
+ assert!(
292
+ css.contains("container-type: inline-size"),
293
+ "@container marker → container-type: inline-size: {css}"
294
+ );
295
+ }
296
+
297
+ #[test]
298
+ fn named_container_marker_sets_type_and_name() {
299
+ let css = compile_sfc_scoped(&sfc_with_classes("@container/sidebar")).unwrap();
300
+ assert!(css.contains("container-type: inline-size"), "{css}");
301
+ assert!(
302
+ css.contains("container-name: sidebar"),
303
+ "@container/sidebar → container-name: sidebar: {css}"
304
+ );
305
+ }
306
+
307
+ #[test]
308
+ fn container_md_wraps_rule_in_container_at_rule() {
309
+ let css = compile_sfc_scoped(&sfc_with_classes("@md:flex")).unwrap();
310
+ assert!(
311
+ css.contains("@container (min-width:"),
312
+ "@md: → @container (min-width: ...): {css}"
313
+ );
314
+ // Container scale differs from the viewport breakpoint scale (md = 28rem).
315
+ assert!(css.contains("28rem"), "container @md = 28rem: {css}");
316
+ assert!(css.contains("display: flex"));
317
+ // Must NOT be an @media rule.
318
+ assert!(!css.contains("@media"), "@md: is a container query, not @media: {css}");
319
+ }
320
+
321
+ #[test]
322
+ fn container_sm_lg_scale() {
323
+ let sm = compile_sfc_scoped(&sfc_with_classes("@sm:block")).unwrap();
324
+ assert!(sm.contains("@container (min-width: 24rem)"), "@sm = 24rem: {sm}");
325
+ let lg = compile_sfc_scoped(&sfc_with_classes("@lg:hidden")).unwrap();
326
+ assert!(lg.contains("@container (min-width: 32rem)"), "@lg = 32rem: {lg}");
327
+ }
328
+
329
+ #[test]
330
+ fn container_parent_and_child_pair() {
331
+ // A @container parent + @md:flex child — the proven user-visible pattern.
332
+ let json = r#"{"tag":"X","astVersion":1,"style":null,"meta":{"name":"X"},
333
+ "template":[{"kind":"element","tag":"div","attrs":[
334
+ {"kind":"static","name":"class","value":"@container"}],
335
+ "children":[{"kind":"element","tag":"div","attrs":[
336
+ {"kind":"static","name":"class","value":"@md:flex"}],"children":[]}]}]}"#;
337
+ let css = compile_sfc_scoped(&ast(json)).unwrap();
338
+ assert!(css.contains("container-type: inline-size"), "{css}");
339
+ assert!(css.contains("@container (min-width: 28rem)"), "{css}");
340
+ }
341
+
342
+ // ── Over-implementation probe: no spurious rules for unknown base utilities ──
343
+
344
+ #[test]
345
+ fn aria_data_without_known_base_emits_nothing() {
346
+ // Unknown base utility behind an aria/data variant must not emit a rule.
347
+ let css = compile_sfc_scoped(&sfc_with_classes(
348
+ "aria-checked:notathing data-[state=open]:alsonope @md:bogus",
349
+ )).unwrap();
350
+ assert!(
351
+ !css.contains("[aria-checked"),
352
+ "no aria selector for unknown base: {css}"
353
+ );
354
+ assert!(
355
+ !css.contains("[data-state"),
356
+ "no data selector for unknown base: {css}"
357
+ );
358
+ assert!(
359
+ !css.contains("@container"),
360
+ "no container at-rule for unknown base: {css}"
361
+ );
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
+ }