@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
@@ -32,14 +32,56 @@ pub enum Variant {
32
32
  HostContextDark,
33
33
 
34
34
  // ── standard ─────────────────────────────────────────────────────────────
35
- /// `hover:`, `focus:`, `focus-visible:`, `active:`, `disabled:` → `&:<pc>`.
35
+ /// `hover:`, `focus:`, `focus-visible:`, `active:`, `disabled:`, `first:`
36
+ /// (→ `:first-child`), `last:`, `open:` → `&:<pc>`.
36
37
  Pseudo(String),
38
+ /// `marker:` → `&::marker`, `placeholder:` → `&::placeholder`,
39
+ /// `before:`/`after:`/`selection:`/`file:` → `&::<pe>`. Pseudo-*element*
40
+ /// variants attach with a double colon (distinct from [`Variant::Pseudo`]).
41
+ PseudoElement(String),
37
42
  /// `dark:` — dark cascade (NOT `:host-context()`).
38
43
  Dark,
39
44
  /// `sm:`/`md:`/`lg:`/`xl:`/`2xl:` → `@media (min-width: …)`.
40
45
  Breakpoint(String),
41
46
  /// `[&>div]:` arbitrary selector → native nesting (`& > div`).
42
47
  ArbitrarySelector(String),
48
+
49
+ // ── relational (group / peer) ────────────────────────────────────────────
50
+ /// `group-hover:`, `group-focus:`, `group-focus-visible:`, `group-active:`,
51
+ /// `group-disabled:` → ancestor-state selector
52
+ /// (`.group:<state> <base>`). The `Option<String>` is the ancestor state
53
+ /// pseudo-class. A bare `group` (no state) is NOT a variant prefix — it is a
54
+ /// marker utility (see `tokens::fixed_utility`) applied directly to the
55
+ /// ancestor element; this arm only ever carries `Some(state)`.
56
+ Group(Option<String>),
57
+ /// `peer-hover:`, `peer-focus:`, `peer-focus-visible:`, `peer-checked:`,
58
+ /// `peer-disabled:` → previous-sibling-state selector
59
+ /// (`.peer:<state> ~ <base>`). As with [`Variant::Group`], the bare `peer`
60
+ /// marker is a utility, not a prefix; this arm only carries `Some(state)`.
61
+ Peer(Option<String>),
62
+
63
+ // ── attribute / container (round 2) ───────────────────────────────────────
64
+ /// `aria-checked:` → `&[aria-checked="true"]` (keyword form, implicit
65
+ /// `="true"`). `aria-[expanded=false]:` carries an explicit `name=value`
66
+ /// payload → `&[aria-expanded="false"]`.
67
+ Aria(AttrMatch),
68
+ /// `data-[state=open]:` → `&[data-state="open"]` (bracket payload
69
+ /// `name=value`). `data-active:` (keyword) → `&[data-active]` (presence).
70
+ Data(AttrMatch),
71
+ /// Container-query breakpoint variant: `@sm:`/`@md:`/`@lg:`/`@xl:`/`@2xl:`
72
+ /// → `@container (min-width: …)`. Wraps the rule like `Breakpoint`, but in
73
+ /// an `@container` at-rule instead of `@media`.
74
+ Container(String),
75
+ }
76
+
77
+ /// An attribute-selector match payload for `aria-*` / `data-*` variants.
78
+ #[derive(Debug, Clone, PartialEq, Eq)]
79
+ pub enum AttrMatch {
80
+ /// `aria-checked` → `[aria-checked="true"]` (aria) or `[data-active]`
81
+ /// presence (data). The bool records whether to imply `="true"`.
82
+ Name { name: String, imply_true: bool },
83
+ /// Explicit `name=value` → `[<full>-name="value"]`.
84
+ NameValue { name: String, value: String },
43
85
  }
44
86
 
45
87
  impl Variant {
@@ -108,17 +150,223 @@ fn parse_prefix(prefix: &str) -> Option<Variant> {
108
150
  "host-context-dark" => Variant::HostContextDark,
109
151
  "slotted" => Variant::Slotted,
110
152
  "dark" => Variant::Dark,
111
- "hover" | "focus" | "focus-visible" | "active" | "disabled" | "visited"
112
- | "checked" => Variant::Pseudo(prefix.to_string()),
153
+ "hover" | "focus" | "focus-visible" | "active" | "disabled" | "visited" | "checked"
154
+ | "required" | "open" => Variant::Pseudo(prefix.to_string()),
155
+ // Structural pseudo-classes map to their `:…-child` / `:nth-child()` form.
156
+ "first" => Variant::Pseudo("first-child".to_string()),
157
+ "last" => Variant::Pseudo("last-child".to_string()),
158
+ "only" => Variant::Pseudo("only-child".to_string()),
159
+ "odd" => Variant::Pseudo("nth-child(odd)".to_string()),
160
+ "even" => Variant::Pseudo("nth-child(even)".to_string()),
161
+ "empty" => Variant::Pseudo("empty".to_string()),
162
+ // Pseudo-elements (double colon).
163
+ "marker" => Variant::PseudoElement("marker".to_string()),
164
+ "placeholder" => Variant::PseudoElement("placeholder".to_string()),
165
+ "before" => Variant::PseudoElement("before".to_string()),
166
+ "after" => Variant::PseudoElement("after".to_string()),
167
+ "selection" => Variant::PseudoElement("selection".to_string()),
168
+ "file" => Variant::PseudoElement("file-selector-button".to_string()),
113
169
  "sm" | "md" | "lg" | "xl" | "2xl" => Variant::Breakpoint(prefix.to_string()),
170
+ // Container-query breakpoints: `@sm`/`@md`/`@lg`/`@xl`/`@2xl`.
171
+ "@sm" | "@md" | "@lg" | "@xl" | "@2xl" => Variant::Container(prefix[1..].to_string()),
114
172
  _ => {
115
173
  if let Some(tag) = prefix.strip_prefix("slotted-") {
116
174
  Variant::SlottedTag(tag.to_string())
117
175
  } else if let Some(name) = prefix.strip_prefix("part-") {
118
176
  Variant::Part(name.to_string())
177
+ } else if let Some(state) = group_state(prefix) {
178
+ Variant::Group(state)
179
+ } else if let Some(state) = peer_state(prefix) {
180
+ Variant::Peer(state)
181
+ } else if let Some(payload) = prefix.strip_prefix("aria-") {
182
+ Variant::Aria(parse_attr_match(payload, true))
183
+ } else if let Some(payload) = prefix.strip_prefix("data-") {
184
+ // data-* never implies `="true"`: bare `data-active` is a
185
+ // presence selector `[data-active]`.
186
+ Variant::Data(parse_attr_match(payload, false))
119
187
  } else {
120
188
  return None;
121
189
  }
122
190
  }
123
191
  })
124
192
  }
193
+
194
+ /// States a `group-*:` prefix may carry. Returns `Some(Some(state))` when
195
+ /// `prefix` is a recognized `group-<state>` form. (`group` alone is a marker
196
+ /// utility, not a variant — so it is NOT matched here.)
197
+ fn group_state(prefix: &str) -> Option<Option<String>> {
198
+ let state = prefix.strip_prefix("group-")?;
199
+ relational_state(state).map(Some)
200
+ }
201
+
202
+ /// States a `peer-*:` prefix may carry. See [`group_state`].
203
+ fn peer_state(prefix: &str) -> Option<Option<String>> {
204
+ let state = prefix.strip_prefix("peer-")?;
205
+ relational_state(state).map(Some)
206
+ }
207
+
208
+ /// The closed set of states `group-*:` / `peer-*:` accept. They map 1:1 to a
209
+ /// pseudo-class on the ancestor / previous-sibling marker element.
210
+ fn relational_state(state: &str) -> Option<String> {
211
+ matches!(
212
+ state,
213
+ "hover" | "focus" | "focus-visible" | "active" | "disabled" | "checked" | "open"
214
+ )
215
+ .then(|| state.to_string())
216
+ }
217
+
218
+ /// How a (possibly empty) variant list resolves against a base selector for the
219
+ /// `@apply` expansion path (`apply.rs`). This is the SHARED structural resolver
220
+ /// (R-APPLY-PARSE): `@apply hover:bg-accent` inside a `.btn { … }` rule must map
221
+ /// to a nested `&:hover { … }` block on the *recipe's own* selector — NOT to a
222
+ /// `.hover\:bg-accent:hover` class rule (Codex confirmed `emit_token` +
223
+ /// string-strip produces wrong output).
224
+ ///
225
+ /// The emitter ([`emit_token`](crate::emit)) starts from a class selector
226
+ /// (`.token`); the `@apply` path starts from `&` (the parent rule). Both walk the
227
+ /// same variant arms, so the per-variant selector transforms live here as
228
+ /// [`apply_variant_to_selector`] and the wrapping/cascade decisions in
229
+ /// [`ResolvedVariants`].
230
+ #[derive(Debug, Clone, PartialEq, Eq)]
231
+ pub struct ResolvedVariants {
232
+ /// The selector the declarations attach to, with each variant applied
233
+ /// against the starting base (`&` for `@apply`). E.g. `&:hover`,
234
+ /// `&[data-state="open"]`, `.group:hover &`.
235
+ pub selector: String,
236
+ /// A wrapping at-rule (`@media (min-width: …)` / `@container (…)`) the rule
237
+ /// nests inside, if a breakpoint/container variant was present.
238
+ pub at_rule: Option<String>,
239
+ /// True when a dark-cascade variant (`dark:`/`host-context-dark:`) was
240
+ /// present — the caller emits the Firefox-safe dark gate instead of a plain
241
+ /// rule.
242
+ pub dark_cascade: bool,
243
+ /// True when any variant implies host/`&`/relational scoping. Used to reject
244
+ /// such variants inside a `$global` `@apply` (Task 1.4 — variants that imply
245
+ /// `&`/host scoping are rejected in `$global`; base utilities allowed).
246
+ pub needs_scope: bool,
247
+ }
248
+
249
+ /// Apply one [`Variant`] to a running selector, mirroring the emitter's arms but
250
+ /// starting from an arbitrary base (`&` for the `@apply` path). Returns the new
251
+ /// selector, plus optional `(at_rule)` / `dark_cascade` / `needs_scope` signals
252
+ /// via the accumulator the caller threads.
253
+ fn apply_variant_to_selector(
254
+ v: &Variant,
255
+ selector: String,
256
+ theme: &crate::theme::ThemeRegistry,
257
+ acc: &mut ResolvedVariants,
258
+ ) -> String {
259
+ match v {
260
+ Variant::Host => {
261
+ acc.needs_scope = true;
262
+ format!(":host({selector})")
263
+ }
264
+ Variant::Slotted => {
265
+ acc.needs_scope = true;
266
+ format!("::slotted({selector})")
267
+ }
268
+ Variant::SlottedTag(tag) => {
269
+ acc.needs_scope = true;
270
+ format!("::slotted({tag}{selector})")
271
+ }
272
+ Variant::Part(name) => {
273
+ acc.needs_scope = true;
274
+ format!("::part({name})")
275
+ }
276
+ Variant::Pseudo(pc) => {
277
+ acc.needs_scope = true;
278
+ format!("{selector}:{pc}")
279
+ }
280
+ Variant::PseudoElement(pe) => {
281
+ acc.needs_scope = true;
282
+ format!("{selector}::{pe}")
283
+ }
284
+ Variant::ArbitrarySelector(sel) => {
285
+ acc.needs_scope = true;
286
+ sel.replace('&', &selector)
287
+ }
288
+ Variant::Group(Some(state)) => {
289
+ acc.needs_scope = true;
290
+ format!(".group:{state} {selector}")
291
+ }
292
+ Variant::Peer(Some(state)) => {
293
+ acc.needs_scope = true;
294
+ format!(".peer:{state} ~ {selector}")
295
+ }
296
+ Variant::Group(None) | Variant::Peer(None) => selector,
297
+ Variant::Aria(m) => {
298
+ acc.needs_scope = true;
299
+ format!("{selector}{}", crate::emit::attr_selector("aria", m))
300
+ }
301
+ Variant::Data(m) => {
302
+ acc.needs_scope = true;
303
+ format!("{selector}{}", crate::emit::attr_selector("data", m))
304
+ }
305
+ Variant::Breakpoint(bp) => {
306
+ if let Some(min) = theme.breakpoint(bp) {
307
+ acc.at_rule = Some(format!("@media (min-width: {min})"));
308
+ }
309
+ selector
310
+ }
311
+ Variant::Container(bp) => {
312
+ if let Some(min) = theme.container_breakpoint(bp) {
313
+ acc.at_rule = Some(format!("@container (min-width: {min})"));
314
+ }
315
+ selector
316
+ }
317
+ Variant::Dark | Variant::HostContextDark => {
318
+ // The dark cascade rewrites `&` to a `:host([data-theme])`/`:root.dark`
319
+ // gate — host/root scope that a `$global` `@apply` cannot express.
320
+ acc.dark_cascade = true;
321
+ acc.needs_scope = true;
322
+ selector
323
+ }
324
+ }
325
+ }
326
+
327
+ /// Resolve a variant list against `base` (`&` for `@apply`) into the structural
328
+ /// selector + wrapping decisions shared by `@apply` expansion.
329
+ pub fn resolve_variants(
330
+ variants: &[Variant],
331
+ base: &str,
332
+ theme: &crate::theme::ThemeRegistry,
333
+ ) -> ResolvedVariants {
334
+ let mut acc = ResolvedVariants {
335
+ selector: base.to_string(),
336
+ at_rule: None,
337
+ dark_cascade: false,
338
+ needs_scope: false,
339
+ };
340
+ let mut selector = base.to_string();
341
+ for v in variants {
342
+ selector = apply_variant_to_selector(v, selector, theme, &mut acc);
343
+ }
344
+ acc.selector = selector;
345
+ acc
346
+ }
347
+
348
+ /// Parse the payload after `aria-`/`data-` into an [`AttrMatch`].
349
+ ///
350
+ /// Two shapes:
351
+ /// - `checked` (keyword) → `Name { name: "checked", imply_true }`.
352
+ /// - `[state=open]` (bracket, from `data-[state=open]`) → `NameValue`. The
353
+ /// bracket-aware splitter in [`split_variants`] keeps the `[...]` attached to
354
+ /// the prefix, so the payload arrives here as `[state=open]`.
355
+ fn parse_attr_match(payload: &str, imply_true: bool) -> AttrMatch {
356
+ // Strip an outer `[...]` if present (arbitrary form).
357
+ let inner = payload
358
+ .strip_prefix('[')
359
+ .and_then(|s| s.strip_suffix(']'))
360
+ .unwrap_or(payload);
361
+ if let Some((name, value)) = inner.split_once('=') {
362
+ AttrMatch::NameValue {
363
+ name: name.trim().to_string(),
364
+ value: value.trim().trim_matches('"').to_string(),
365
+ }
366
+ } else {
367
+ AttrMatch::Name {
368
+ name: inner.trim().to_string(),
369
+ imply_true,
370
+ }
371
+ }
372
+ }
@@ -0,0 +1,203 @@
1
+ //! `@apply` expansion edge matrix (Task 1.4 — R-APPLY-PARSE, R-APPLY-TESTS).
2
+ //!
3
+ //! Drives `compile_sfc_scoped` with authored `@style` blocks containing `@apply`
4
+ //! directives and snapshots the folded output. The matrix (per R-APPLY-TESTS):
5
+ //! base; single variant → nested; multi-token `@apply a b c`; multiple `@apply`
6
+ //! per rule; `@apply` in a nested rule; arbitrary-value `bg-[#fff]`; unknown-
7
+ //! utility error; `$global` variant rejection.
8
+
9
+ use aihu_css_core::{compile_sfc_scoped, parse_ast, CompileError, SfcStyleScope};
10
+
11
+ /// Build an SFC AST with the given authored `@style` content + scope. No
12
+ /// template classes so the snapshot is JUST theme tokens + the folded `@style`,
13
+ /// isolating the `@apply` expansion.
14
+ fn sfc_style(content: &str, scope: &str) -> aihu_css_core::SfcAst {
15
+ let json = format!(
16
+ r#"{{"tag":"X","astVersion":1,
17
+ "style":{{"content":{content},"scope":"{scope}"}},
18
+ "meta":{{"name":"X"}},"template":null}}"#,
19
+ content = serde_json::to_string(content).unwrap()
20
+ );
21
+ parse_ast(&json).unwrap()
22
+ }
23
+
24
+ fn scoped(content: &str) -> String {
25
+ compile_sfc_scoped(&sfc_style(content, "scoped")).unwrap()
26
+ }
27
+
28
+ /// Compile via `expand_apply` directly (parser → expand → css) so the snapshot
29
+ /// is just the expanded `@style`, with no theme-token preamble.
30
+ fn expand(content: &str, scope: SfcStyleScope) -> Result<String, CompileError> {
31
+ aihu_css_core::expand_apply(content, scope, &aihu_css_core::ThemeRegistry::with_aihu_defaults())
32
+ }
33
+
34
+ // ── base utility inline ──────────────────────────────────────────────────────
35
+
36
+ #[test]
37
+ fn base_utility_inlines_declarations() {
38
+ let css = expand(".btn { @apply inline-flex items-center; }", SfcStyleScope::Scoped).unwrap();
39
+ insta::assert_snapshot!(css);
40
+ }
41
+
42
+ // ── single variant → nested rule ─────────────────────────────────────────────
43
+
44
+ #[test]
45
+ fn single_variant_lifts_to_nested_rule() {
46
+ // hover: → &:hover { … } on the recipe's OWN selector (NOT a
47
+ // `.hover\:bg-accent:hover` class rule).
48
+ let css = expand(".btn { @apply hover:bg-accent; }", SfcStyleScope::Scoped).unwrap();
49
+ assert!(css.contains("&:hover"), "variant lifts to nested &:hover: {css}");
50
+ assert!(
51
+ !css.contains(r"\:"),
52
+ "must NOT emit an escaped class selector: {css}"
53
+ );
54
+ insta::assert_snapshot!(css);
55
+ }
56
+
57
+ // ── multi-token @apply a b c ─────────────────────────────────────────────────
58
+
59
+ #[test]
60
+ fn multi_token_apply() {
61
+ let css = expand(
62
+ ".btn { @apply inline-flex items-center justify-center rounded-md; }",
63
+ SfcStyleScope::Scoped,
64
+ )
65
+ .unwrap();
66
+ insta::assert_snapshot!(css);
67
+ }
68
+
69
+ // ── multiple @apply per rule ─────────────────────────────────────────────────
70
+
71
+ #[test]
72
+ fn multiple_apply_directives_per_rule() {
73
+ let css = expand(
74
+ ".btn {\n @apply inline-flex;\n @apply rounded-md;\n @apply hover:bg-accent;\n}",
75
+ SfcStyleScope::Scoped,
76
+ )
77
+ .unwrap();
78
+ insta::assert_snapshot!(css);
79
+ }
80
+
81
+ // ── @apply in a nested rule ──────────────────────────────────────────────────
82
+
83
+ #[test]
84
+ fn apply_inside_nested_rule() {
85
+ let css = expand(
86
+ ".card {\n display: grid;\n & .title { @apply font-medium; }\n}",
87
+ SfcStyleScope::Scoped,
88
+ )
89
+ .unwrap();
90
+ assert!(css.contains("font-weight: 500"), "nested @apply inlined: {css}");
91
+ insta::assert_snapshot!(css);
92
+ }
93
+
94
+ // ── arbitrary-value utility ──────────────────────────────────────────────────
95
+
96
+ #[test]
97
+ fn arbitrary_value_utility_in_apply() {
98
+ let css = expand(".swatch { @apply bg-[#fff]; }", SfcStyleScope::Scoped).unwrap();
99
+ assert!(
100
+ css.contains("background-color: #fff"),
101
+ "arbitrary value inlined verbatim: {css}"
102
+ );
103
+ insta::assert_snapshot!(css);
104
+ }
105
+
106
+ #[test]
107
+ fn arbitrary_value_variant_in_apply() {
108
+ let css = expand(".swatch { @apply hover:bg-[#fff]; }", SfcStyleScope::Scoped).unwrap();
109
+ assert!(css.contains("&:hover"));
110
+ assert!(css.contains("background-color: #fff"));
111
+ insta::assert_snapshot!(css);
112
+ }
113
+
114
+ // ── responsive + data + group variants ───────────────────────────────────────
115
+
116
+ #[test]
117
+ fn responsive_variant_wraps_media() {
118
+ let css = expand(".grid { @apply md:flex; }", SfcStyleScope::Scoped).unwrap();
119
+ assert!(css.contains("@media (min-width:"), "md: wraps @media: {css}");
120
+ insta::assert_snapshot!(css);
121
+ }
122
+
123
+ #[test]
124
+ fn data_attribute_variant() {
125
+ let css = expand(
126
+ r#".btn { @apply data-[state=open]:bg-accent; }"#,
127
+ SfcStyleScope::Scoped,
128
+ )
129
+ .unwrap();
130
+ assert!(css.contains(r#"&[data-state="open"]"#), "{css}");
131
+ insta::assert_snapshot!(css);
132
+ }
133
+
134
+ #[test]
135
+ fn dark_variant_cascade_in_apply() {
136
+ let css = expand(".panel { @apply dark:bg-surface; }", SfcStyleScope::Scoped).unwrap();
137
+ assert!(
138
+ !css.contains(":host-context("),
139
+ "Firefox-safe: no :host-context(): {css}"
140
+ );
141
+ assert!(css.contains(r#":host([data-theme="dark"])"#), "{css}");
142
+ assert!(css.contains(":root.dark"), "{css}");
143
+ insta::assert_snapshot!(css);
144
+ }
145
+
146
+ // ── unknown utility error ────────────────────────────────────────────────────
147
+
148
+ #[test]
149
+ fn unknown_utility_is_compile_error() {
150
+ let err = expand(".btn { @apply totally-not-a-utility; }", SfcStyleScope::Scoped)
151
+ .expect_err("unknown utility must hard-error");
152
+ match err {
153
+ CompileError::UnknownApplyUtility { ref token } => {
154
+ assert_eq!(token, "totally-not-a-utility")
155
+ }
156
+ other => panic!("expected UnknownApplyUtility, got {other:?}"),
157
+ }
158
+ assert!(err.to_string().contains("unknown utility in @apply"));
159
+ }
160
+
161
+ #[test]
162
+ fn unknown_utility_propagates_through_compile_sfc_scoped() {
163
+ let err = compile_sfc_scoped(&sfc_style(".btn { @apply nope-nope; }", "scoped"))
164
+ .expect_err("must propagate");
165
+ assert!(matches!(err, CompileError::UnknownApplyUtility { .. }));
166
+ }
167
+
168
+ // ── $global variant rejection ────────────────────────────────────────────────
169
+
170
+ #[test]
171
+ fn global_apply_rejects_scope_implying_variant() {
172
+ let err = expand("body { @apply hover:bg-accent; }", SfcStyleScope::Global)
173
+ .expect_err("$global may not use a scope-implying variant");
174
+ match err {
175
+ CompileError::GlobalApplyVariant { ref token } => assert_eq!(token, "hover:bg-accent"),
176
+ other => panic!("expected GlobalApplyVariant, got {other:?}"),
177
+ }
178
+ }
179
+
180
+ #[test]
181
+ fn global_apply_allows_base_utility() {
182
+ // Base utilities are fine in $global — they inline as flat declarations.
183
+ let css = expand("body { @apply inline-flex; }", SfcStyleScope::Global).unwrap();
184
+ assert!(css.contains("display: inline-flex"), "{css}");
185
+ }
186
+
187
+ #[test]
188
+ fn global_apply_allows_media_variant() {
189
+ // A breakpoint variant implies no host/`&` scope (it only wraps in @media),
190
+ // so it is allowed in $global.
191
+ let css = expand("body { @apply md:flex; }", SfcStyleScope::Global).unwrap();
192
+ assert!(css.contains("@media (min-width:"), "{css}");
193
+ }
194
+
195
+ // ── end-to-end through compile_sfc_scoped (theme preamble included) ───────────
196
+
197
+ #[test]
198
+ fn end_to_end_scoped_with_theme_preamble() {
199
+ let css = scoped(".btn { @apply inline-flex hover:bg-accent; }");
200
+ assert!(css.contains(":host {"), "theme tokens present: {css}");
201
+ assert!(css.contains("display: inline-flex"));
202
+ assert!(css.contains("&:hover"));
203
+ }
@@ -0,0 +1,150 @@
1
+ //! R-NO-PREMIGRATION-BREAK (CRITICAL): every current `packages/ui/registry/*`
2
+ //! recipe's authored `@style` block must pass through `@apply` expansion with no
3
+ //! hard-error. If a recipe uses a utility token the table does not cover, this
4
+ //! test fails — and the fix is to cover the token in `tokens.rs` (or fix the
5
+ //! recipe) BEFORE enabling expansion, so turning `@apply` on does not break the
6
+ //! shipped recipes.
7
+ //!
8
+ //! The test reads the `.aihu` SFC files directly (relative to the crate, two
9
+ //! levels up to the repo root), extracts each `@style { … }` block body with a
10
+ //! brace-matched scan, strips `@theme` blocks (handled separately by the
11
+ //! emitter), and runs `expand_apply` on the remainder.
12
+
13
+ use std::path::PathBuf;
14
+
15
+ use aihu_css_core::{expand_apply, SfcStyleScope, ThemeRegistry};
16
+
17
+ /// The repo root, derived from this crate's manifest dir
18
+ /// (`<root>/packages/css-engine/crates/aihu-css-core`).
19
+ fn repo_root() -> PathBuf {
20
+ let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
21
+ for _ in 0..4 {
22
+ p.pop();
23
+ }
24
+ p
25
+ }
26
+
27
+ /// Extract the body inside `@style { … }` (brace-matched), or `None` if the file
28
+ /// has no `@style` block. `$global @style` is not used by the current recipes,
29
+ /// but we treat any `@style` block as scoped for this regression (the only
30
+ /// concern is whether tokens RESOLVE, which is scope-independent for base
31
+ /// utilities; variant tokens resolve identically — only the $global *rejection*
32
+ /// path differs, and none of these recipes use $global).
33
+ fn extract_style_body(src: &str) -> Option<String> {
34
+ // Find the `@style` BLOCK opener — `@style` followed only by whitespace then
35
+ // `{`. (A naive `find("@style")` would match the word inside a comment such
36
+ // as "see @style below"; the recipes contain exactly that.)
37
+ let mut search = 0usize;
38
+ let (at, after) = loop {
39
+ let rel = src[search..].find("@style")?;
40
+ let at = search + rel;
41
+ let after = &src[at + "@style".len()..];
42
+ let trimmed = after.trim_start();
43
+ if trimmed.starts_with('{') {
44
+ break (at, after);
45
+ }
46
+ search = at + "@style".len();
47
+ };
48
+ let _ = at;
49
+ let open = after.find('{')?;
50
+ let body_start = open + 1;
51
+ let bytes = after.as_bytes();
52
+ let mut depth = 1i32;
53
+ let mut i = body_start;
54
+ while i < bytes.len() {
55
+ match bytes[i] {
56
+ b'{' => depth += 1,
57
+ b'}' => {
58
+ depth -= 1;
59
+ if depth == 0 {
60
+ return Some(after[body_start..i].to_string());
61
+ }
62
+ }
63
+ _ => {}
64
+ }
65
+ i += 1;
66
+ }
67
+ None
68
+ }
69
+
70
+ /// Remove `@theme { … }` blocks (brace-matched) — the emitter strips these
71
+ /// before folding, so the `@apply` pass never sees them.
72
+ fn strip_theme(body: &str) -> String {
73
+ let mut out = String::new();
74
+ let mut rest = body;
75
+ while let Some(at) = rest.find("@theme") {
76
+ out.push_str(&rest[..at]);
77
+ let after = &rest[at + "@theme".len()..];
78
+ let Some(open) = after.find('{') else {
79
+ out.push_str(after);
80
+ return out;
81
+ };
82
+ let bytes = after.as_bytes();
83
+ let mut depth = 1i32;
84
+ let mut i = open + 1;
85
+ while i < bytes.len() {
86
+ match bytes[i] {
87
+ b'{' => depth += 1,
88
+ b'}' => {
89
+ depth -= 1;
90
+ if depth == 0 {
91
+ break;
92
+ }
93
+ }
94
+ _ => {}
95
+ }
96
+ i += 1;
97
+ }
98
+ rest = &after[i + 1..];
99
+ }
100
+ out.push_str(rest);
101
+ out
102
+ }
103
+
104
+ #[test]
105
+ fn every_registry_recipe_apply_resolves() {
106
+ let registry = repo_root().join("packages/ui/registry");
107
+ assert!(
108
+ registry.is_dir(),
109
+ "registry dir not found at {}",
110
+ registry.display()
111
+ );
112
+
113
+ let theme = ThemeRegistry::with_aihu_defaults();
114
+ let mut checked = 0usize;
115
+
116
+ for entry in std::fs::read_dir(&registry).unwrap() {
117
+ let dir = entry.unwrap().path();
118
+ if !dir.is_dir() {
119
+ continue;
120
+ }
121
+ for file in std::fs::read_dir(&dir).unwrap() {
122
+ let path = file.unwrap().path();
123
+ if path.extension().and_then(|s| s.to_str()) != Some("aihu") {
124
+ continue;
125
+ }
126
+ let src = std::fs::read_to_string(&path).unwrap();
127
+ let Some(body) = extract_style_body(&src) else {
128
+ continue;
129
+ };
130
+ if !body.contains("@apply") {
131
+ // No @apply → nothing to expand, but still assert it parses.
132
+ continue;
133
+ }
134
+ let stripped = strip_theme(&body);
135
+ let result = expand_apply(&stripped, SfcStyleScope::Scoped, &theme);
136
+ assert!(
137
+ result.is_ok(),
138
+ "recipe {} @apply expansion failed: {:?}",
139
+ path.display(),
140
+ result.err()
141
+ );
142
+ checked += 1;
143
+ }
144
+ }
145
+
146
+ assert!(
147
+ checked > 0,
148
+ "expected at least one recipe with @apply (button) to be checked"
149
+ );
150
+ }