@aihu/css-engine 0.2.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -40,6 +40,43 @@ pub enum Variant {
40
40
  Breakpoint(String),
41
41
  /// `[&>div]:` arbitrary selector → native nesting (`& > div`).
42
42
  ArbitrarySelector(String),
43
+
44
+ // ── relational (group / peer) ────────────────────────────────────────────
45
+ /// `group-hover:`, `group-focus:`, `group-focus-visible:`, `group-active:`,
46
+ /// `group-disabled:` → ancestor-state selector
47
+ /// (`.group:<state> <base>`). The `Option<String>` is the ancestor state
48
+ /// pseudo-class. A bare `group` (no state) is NOT a variant prefix — it is a
49
+ /// marker utility (see `tokens::fixed_utility`) applied directly to the
50
+ /// ancestor element; this arm only ever carries `Some(state)`.
51
+ Group(Option<String>),
52
+ /// `peer-hover:`, `peer-focus:`, `peer-focus-visible:`, `peer-checked:`,
53
+ /// `peer-disabled:` → previous-sibling-state selector
54
+ /// (`.peer:<state> ~ <base>`). As with [`Variant::Group`], the bare `peer`
55
+ /// marker is a utility, not a prefix; this arm only carries `Some(state)`.
56
+ Peer(Option<String>),
57
+
58
+ // ── attribute / container (round 2) ───────────────────────────────────────
59
+ /// `aria-checked:` → `&[aria-checked="true"]` (keyword form, implicit
60
+ /// `="true"`). `aria-[expanded=false]:` carries an explicit `name=value`
61
+ /// payload → `&[aria-expanded="false"]`.
62
+ Aria(AttrMatch),
63
+ /// `data-[state=open]:` → `&[data-state="open"]` (bracket payload
64
+ /// `name=value`). `data-active:` (keyword) → `&[data-active]` (presence).
65
+ Data(AttrMatch),
66
+ /// Container-query breakpoint variant: `@sm:`/`@md:`/`@lg:`/`@xl:`/`@2xl:`
67
+ /// → `@container (min-width: …)`. Wraps the rule like `Breakpoint`, but in
68
+ /// an `@container` at-rule instead of `@media`.
69
+ Container(String),
70
+ }
71
+
72
+ /// An attribute-selector match payload for `aria-*` / `data-*` variants.
73
+ #[derive(Debug, Clone, PartialEq, Eq)]
74
+ pub enum AttrMatch {
75
+ /// `aria-checked` → `[aria-checked="true"]` (aria) or `[data-active]`
76
+ /// presence (data). The bool records whether to imply `="true"`.
77
+ Name { name: String, imply_true: bool },
78
+ /// Explicit `name=value` → `[<full>-name="value"]`.
79
+ NameValue { name: String, value: String },
43
80
  }
44
81
 
45
82
  impl Variant {
@@ -111,14 +148,78 @@ fn parse_prefix(prefix: &str) -> Option<Variant> {
111
148
  "hover" | "focus" | "focus-visible" | "active" | "disabled" | "visited"
112
149
  | "checked" => Variant::Pseudo(prefix.to_string()),
113
150
  "sm" | "md" | "lg" | "xl" | "2xl" => Variant::Breakpoint(prefix.to_string()),
151
+ // Container-query breakpoints: `@sm`/`@md`/`@lg`/`@xl`/`@2xl`.
152
+ "@sm" | "@md" | "@lg" | "@xl" | "@2xl" => {
153
+ Variant::Container(prefix[1..].to_string())
154
+ }
114
155
  _ => {
115
156
  if let Some(tag) = prefix.strip_prefix("slotted-") {
116
157
  Variant::SlottedTag(tag.to_string())
117
158
  } else if let Some(name) = prefix.strip_prefix("part-") {
118
159
  Variant::Part(name.to_string())
160
+ } else if let Some(state) = group_state(prefix) {
161
+ Variant::Group(state)
162
+ } else if let Some(state) = peer_state(prefix) {
163
+ Variant::Peer(state)
164
+ } else if let Some(payload) = prefix.strip_prefix("aria-") {
165
+ Variant::Aria(parse_attr_match(payload, true))
166
+ } else if let Some(payload) = prefix.strip_prefix("data-") {
167
+ // data-* never implies `="true"`: bare `data-active` is a
168
+ // presence selector `[data-active]`.
169
+ Variant::Data(parse_attr_match(payload, false))
119
170
  } else {
120
171
  return None;
121
172
  }
122
173
  }
123
174
  })
124
175
  }
176
+
177
+ /// States a `group-*:` prefix may carry. Returns `Some(Some(state))` when
178
+ /// `prefix` is a recognized `group-<state>` form. (`group` alone is a marker
179
+ /// utility, not a variant — so it is NOT matched here.)
180
+ fn group_state(prefix: &str) -> Option<Option<String>> {
181
+ let state = prefix.strip_prefix("group-")?;
182
+ relational_state(state).map(Some)
183
+ }
184
+
185
+ /// States a `peer-*:` prefix may carry. See [`group_state`].
186
+ fn peer_state(prefix: &str) -> Option<Option<String>> {
187
+ let state = prefix.strip_prefix("peer-")?;
188
+ relational_state(state).map(Some)
189
+ }
190
+
191
+ /// The closed set of states `group-*:` / `peer-*:` accept. They map 1:1 to a
192
+ /// pseudo-class on the ancestor / previous-sibling marker element.
193
+ fn relational_state(state: &str) -> Option<String> {
194
+ matches!(
195
+ state,
196
+ "hover" | "focus" | "focus-visible" | "active" | "disabled" | "checked"
197
+ )
198
+ .then(|| state.to_string())
199
+ }
200
+
201
+ /// Parse the payload after `aria-`/`data-` into an [`AttrMatch`].
202
+ ///
203
+ /// Two shapes:
204
+ /// - `checked` (keyword) → `Name { name: "checked", imply_true }`.
205
+ /// - `[state=open]` (bracket, from `data-[state=open]`) → `NameValue`. The
206
+ /// bracket-aware splitter in [`split_variants`] keeps the `[...]` attached to
207
+ /// the prefix, so the payload arrives here as `[state=open]`.
208
+ fn parse_attr_match(payload: &str, imply_true: bool) -> AttrMatch {
209
+ // Strip an outer `[...]` if present (arbitrary form).
210
+ let inner = payload
211
+ .strip_prefix('[')
212
+ .and_then(|s| s.strip_suffix(']'))
213
+ .unwrap_or(payload);
214
+ if let Some((name, value)) = inner.split_once('=') {
215
+ AttrMatch::NameValue {
216
+ name: name.trim().to_string(),
217
+ value: value.trim().trim_matches('"').to_string(),
218
+ }
219
+ } else {
220
+ AttrMatch::Name {
221
+ name: inner.trim().to_string(),
222
+ imply_true,
223
+ }
224
+ }
225
+ }
@@ -122,6 +122,72 @@ fn stacked_variants_md_hover() {
122
122
  assert!(css.contains(":hover"));
123
123
  }
124
124
 
125
+ // ── Round 2: group / peer relational variants ────────────────────────────────
126
+
127
+ #[test]
128
+ fn group_hover_emits_ancestor_state_selector() {
129
+ let css = compile_sfc_scoped(&sfc_with_classes("group-hover:bg-primary"));
130
+ // Ancestor descendant-combinator: `.group:hover .group-hover\:bg-primary`.
131
+ assert!(
132
+ css.contains(".group:hover .group-hover\\:bg-primary"),
133
+ "group-hover: → `.group:hover <base>` ancestor selector: {css}"
134
+ );
135
+ assert!(css.contains("background-color: var(--color-primary)"));
136
+ }
137
+
138
+ #[test]
139
+ fn group_focus_variants_emit_each_state() {
140
+ for (cls, sel) in [
141
+ ("group-focus:bg-primary", ".group:focus "),
142
+ ("group-focus-visible:bg-primary", ".group:focus-visible "),
143
+ ("group-active:bg-primary", ".group:active "),
144
+ ("group-disabled:bg-primary", ".group:disabled "),
145
+ ] {
146
+ let css = compile_sfc_scoped(&sfc_with_classes(cls));
147
+ assert!(css.contains(sel), "{cls} → `{sel}` prefix: {css}");
148
+ }
149
+ }
150
+
151
+ #[test]
152
+ fn peer_checked_emits_sibling_state_selector() {
153
+ let css = compile_sfc_scoped(&sfc_with_classes("peer-checked:bg-primary"));
154
+ // Subsequent-sibling combinator: `.peer:checked ~ .peer-checked\:bg-primary`.
155
+ assert!(
156
+ css.contains(".peer:checked ~ .peer-checked\\:bg-primary"),
157
+ "peer-checked: → `.peer:checked ~ <base>` sibling selector: {css}"
158
+ );
159
+ assert!(css.contains("background-color: var(--color-primary)"));
160
+ }
161
+
162
+ #[test]
163
+ fn peer_state_variants_emit_each_state() {
164
+ for (cls, sel) in [
165
+ ("peer-hover:bg-primary", ".peer:hover ~ "),
166
+ ("peer-focus:bg-primary", ".peer:focus ~ "),
167
+ ("peer-focus-visible:bg-primary", ".peer:focus-visible ~ "),
168
+ ("peer-disabled:bg-primary", ".peer:disabled ~ "),
169
+ ] {
170
+ let css = compile_sfc_scoped(&sfc_with_classes(cls));
171
+ assert!(css.contains(sel), "{cls} → `{sel}` prefix: {css}");
172
+ }
173
+ }
174
+
175
+ #[test]
176
+ fn bare_group_and_peer_markers_emit_empty_rules() {
177
+ let css = compile_sfc_scoped(&sfc_with_classes("group peer"));
178
+ // Markers survive as empty-body rules so the relational selectors resolve.
179
+ assert!(css.contains(".group {"), "bare `group` marker kept: {css}");
180
+ assert!(css.contains(".peer {"), "bare `peer` marker kept: {css}");
181
+ }
182
+
183
+ #[test]
184
+ fn group_peer_stack_with_responsive() {
185
+ // `md:group-hover:bg-primary` — breakpoint wraps the relational rule.
186
+ let css = compile_sfc_scoped(&sfc_with_classes("md:group-hover:bg-primary"));
187
+ assert!(css.contains("@media (min-width:"), "breakpoint wrapper: {css}");
188
+ assert!(css.contains(".group:hover "), "relational selector inside media: {css}");
189
+ }
190
+
125
191
  // ── Task 7: @theme directive ─────────────────────────────────────────────────
126
192
 
127
193
  #[test]
@@ -146,3 +212,145 @@ fn default_aihu_brand_tokens_present() {
146
212
  // Default accent is the aihu terracotta.
147
213
  assert!(css.contains("--color-accent: #c8543a"), "baked aihu brand default present: {css}");
148
214
  }
215
+
216
+ // ── Round 2: aria-* / data-* attribute variants ─────────────────────────────
217
+
218
+ #[test]
219
+ fn aria_keyword_variant_emits_true_attr_selector() {
220
+ let css = compile_sfc_scoped(&sfc_with_classes("aria-checked:bg-accent"));
221
+ assert!(
222
+ css.contains(r#"[aria-checked="true"]"#),
223
+ "aria-checked: → [aria-checked=\"true\"] selector: {css}"
224
+ );
225
+ assert!(css.contains("background-color: var(--color-accent)"));
226
+ }
227
+
228
+ #[test]
229
+ fn aria_expanded_variant() {
230
+ let css = compile_sfc_scoped(&sfc_with_classes("aria-expanded:underline"));
231
+ assert!(
232
+ css.contains(r#"[aria-expanded="true"]"#),
233
+ "aria-expanded: → [aria-expanded=\"true\"]: {css}"
234
+ );
235
+ assert!(css.contains("text-decoration-line: underline"));
236
+ }
237
+
238
+ #[test]
239
+ fn aria_disabled_selected_pressed_variants() {
240
+ for (cls, attr) in [
241
+ ("aria-disabled:opacity-50", r#"[aria-disabled="true"]"#),
242
+ ("aria-selected:bg-primary", r#"[aria-selected="true"]"#),
243
+ ("aria-pressed:bg-accent", r#"[aria-pressed="true"]"#),
244
+ ] {
245
+ let css = compile_sfc_scoped(&sfc_with_classes(cls));
246
+ assert!(css.contains(attr), "{cls} → {attr}: {css}");
247
+ }
248
+ }
249
+
250
+ #[test]
251
+ fn aria_arbitrary_value_variant() {
252
+ let css = compile_sfc_scoped(&sfc_with_classes("aria-[expanded=false]:underline"));
253
+ assert!(
254
+ css.contains(r#"[aria-expanded="false"]"#),
255
+ "aria-[expanded=false]: → [aria-expanded=\"false\"]: {css}"
256
+ );
257
+ }
258
+
259
+ #[test]
260
+ fn data_state_variant_emits_attr_selector() {
261
+ let css = compile_sfc_scoped(&sfc_with_classes("data-[state=open]:bg-accent"));
262
+ assert!(
263
+ css.contains(r#"[data-state="open"]"#),
264
+ "data-[state=open]: → [data-state=\"open\"] selector: {css}"
265
+ );
266
+ assert!(css.contains("background-color: var(--color-accent)"));
267
+ }
268
+
269
+ #[test]
270
+ fn data_keyword_variant_is_presence_selector() {
271
+ // Bare `data-active:` is a presence selector (no implicit ="true").
272
+ let css = compile_sfc_scoped(&sfc_with_classes("data-active:underline"));
273
+ assert!(
274
+ css.contains("[data-active]"),
275
+ "data-active: → [data-active] presence selector: {css}"
276
+ );
277
+ assert!(!css.contains(r#"[data-active="true"]"#));
278
+ }
279
+
280
+ // ── Round 2: container queries (@container) ──────────────────────────────────
281
+
282
+ #[test]
283
+ fn container_marker_sets_container_type() {
284
+ let css = compile_sfc_scoped(&sfc_with_classes("@container"));
285
+ assert!(
286
+ css.contains("container-type: inline-size"),
287
+ "@container marker → container-type: inline-size: {css}"
288
+ );
289
+ }
290
+
291
+ #[test]
292
+ fn named_container_marker_sets_type_and_name() {
293
+ let css = compile_sfc_scoped(&sfc_with_classes("@container/sidebar"));
294
+ assert!(css.contains("container-type: inline-size"), "{css}");
295
+ assert!(
296
+ css.contains("container-name: sidebar"),
297
+ "@container/sidebar → container-name: sidebar: {css}"
298
+ );
299
+ }
300
+
301
+ #[test]
302
+ fn container_md_wraps_rule_in_container_at_rule() {
303
+ let css = compile_sfc_scoped(&sfc_with_classes("@md:flex"));
304
+ assert!(
305
+ css.contains("@container (min-width:"),
306
+ "@md: → @container (min-width: ...): {css}"
307
+ );
308
+ // Container scale differs from the viewport breakpoint scale (md = 28rem).
309
+ assert!(css.contains("28rem"), "container @md = 28rem: {css}");
310
+ assert!(css.contains("display: flex"));
311
+ // Must NOT be an @media rule.
312
+ assert!(!css.contains("@media"), "@md: is a container query, not @media: {css}");
313
+ }
314
+
315
+ #[test]
316
+ fn container_sm_lg_scale() {
317
+ let sm = compile_sfc_scoped(&sfc_with_classes("@sm:block"));
318
+ assert!(sm.contains("@container (min-width: 24rem)"), "@sm = 24rem: {sm}");
319
+ let lg = compile_sfc_scoped(&sfc_with_classes("@lg:hidden"));
320
+ assert!(lg.contains("@container (min-width: 32rem)"), "@lg = 32rem: {lg}");
321
+ }
322
+
323
+ #[test]
324
+ fn container_parent_and_child_pair() {
325
+ // A @container parent + @md:flex child — the proven user-visible pattern.
326
+ let json = r#"{"tag":"X","astVersion":1,"style":null,"meta":{"name":"X"},
327
+ "template":[{"kind":"element","tag":"div","attrs":[
328
+ {"kind":"static","name":"class","value":"@container"}],
329
+ "children":[{"kind":"element","tag":"div","attrs":[
330
+ {"kind":"static","name":"class","value":"@md:flex"}],"children":[]}]}]}"#;
331
+ let css = compile_sfc_scoped(&ast(json));
332
+ assert!(css.contains("container-type: inline-size"), "{css}");
333
+ assert!(css.contains("@container (min-width: 28rem)"), "{css}");
334
+ }
335
+
336
+ // ── Over-implementation probe: no spurious rules for unknown base utilities ──
337
+
338
+ #[test]
339
+ fn aria_data_without_known_base_emits_nothing() {
340
+ // Unknown base utility behind an aria/data variant must not emit a rule.
341
+ let css = compile_sfc_scoped(&sfc_with_classes(
342
+ "aria-checked:notathing data-[state=open]:alsonope @md:bogus",
343
+ ));
344
+ assert!(
345
+ !css.contains("[aria-checked"),
346
+ "no aria selector for unknown base: {css}"
347
+ );
348
+ assert!(
349
+ !css.contains("[data-state"),
350
+ "no data selector for unknown base: {css}"
351
+ );
352
+ assert!(
353
+ !css.contains("@container"),
354
+ "no container at-rule for unknown base: {css}"
355
+ );
356
+ }
@@ -46,6 +46,38 @@ fn scoped_with_global_style_block() {
46
46
  insta::assert_snapshot!(compile_sfc_scoped(&ast(json)));
47
47
  }
48
48
 
49
+ #[test]
50
+ fn scoped_space_y_nested_rule() {
51
+ // Locks in the nested `& > * + *` sibling-margin shape inside component
52
+ // scope (Round 1: tailwind-support `space-x/y-*` family).
53
+ insta::assert_snapshot!(compile_sfc_scoped(&sfc("space-y-4")));
54
+ }
55
+
56
+ #[test]
57
+ fn scoped_divide_y_nested_rule() {
58
+ // Locks in the nested `& > * + *` sibling-border shape inside component
59
+ // scope (Round 2: tailwind-support `divide-x/y-*` family). Confirms the
60
+ // nested rule survives the scoped CSS-nesting emission path, mirroring
61
+ // `scoped_space_y_nested_rule`.
62
+ insta::assert_snapshot!(compile_sfc_scoped(&sfc("divide-y-2")));
63
+ }
64
+
65
+ #[test]
66
+ fn scoped_animate_spin_hoists_keyframes() {
67
+ // Locks the `animate-*` emission shape: the scoped `.animate-spin` rule
68
+ // followed by a hoisted top-level `@keyframes spin` sibling (Round 2:
69
+ // tailwind-support `motion` track).
70
+ insta::assert_snapshot!(compile_sfc_scoped(&sfc("animate-spin")));
71
+ }
72
+
73
+ #[test]
74
+ fn scoped_transition_and_transform() {
75
+ // Locks transition shorthand + a transform utility under component scope.
76
+ insta::assert_snapshot!(compile_sfc_scoped(&sfc(
77
+ "transition-transform duration-300 hover:scale-105"
78
+ )));
79
+ }
80
+
49
81
  #[test]
50
82
  fn wc_native_variants() {
51
83
  insta::assert_snapshot!(compile_sfc_scoped(&sfc(
@@ -69,5 +101,7 @@ fn theme_default_vs_override() {
69
101
  "template":[{"kind":"element","tag":"div","attrs":[
70
102
  {"kind":"static","name":"class","value":"bg-primary"}],"children":[]}]}"#;
71
103
  let overridden = compile_sfc_scoped(&ast(json));
72
- insta::assert_snapshot!(format!("--- default ---\n{default}\n--- override ---\n{overridden}"));
104
+ insta::assert_snapshot!(format!(
105
+ "--- default ---\n{default}\n--- override ---\n{overridden}"
106
+ ));
73
107
  }
@@ -0,0 +1,25 @@
1
+ ---
2
+ source: packages/css-engine/crates/aihu-css-core/tests/scoped_snapshot.rs
3
+ assertion_line: 61
4
+ expression: "compile_sfc_scoped(&sfc(\"animate-spin\"))"
5
+ ---
6
+ :host {
7
+ --color-accent: #c8543a;
8
+ --color-accent-foreground: #faf8f4;
9
+ --color-background: #faf8f4;
10
+ --color-border: #ddd9d2;
11
+ --color-destructive: #a8432b;
12
+ --color-destructive-foreground: #faf8f4;
13
+ --color-foreground: #1a1d24;
14
+ --color-muted: #5a5a55;
15
+ --color-muted-foreground: #8a8880;
16
+ --color-primary: #1a1d24;
17
+ --color-primary-foreground: #faf8f4;
18
+ --color-ring: #c8543a;
19
+ --color-secondary: #5a5a55;
20
+ --color-secondary-foreground: #faf8f4;
21
+ --color-surface: #faf8f4;
22
+ --color-surface-foreground: #1a1d24;
23
+ }
24
+ .animate-spin { animation: spin 1s linear infinite; }
25
+ @keyframes spin { to { transform: rotate(360deg); } }
@@ -0,0 +1,23 @@
1
+ ---
2
+ source: packages/css-engine/crates/aihu-css-core/tests/scoped_snapshot.rs
3
+ expression: "compile_sfc_scoped(&sfc(\"divide-y-2\"))"
4
+ ---
5
+ :host {
6
+ --color-accent: #c8543a;
7
+ --color-accent-foreground: #faf8f4;
8
+ --color-background: #faf8f4;
9
+ --color-border: #ddd9d2;
10
+ --color-destructive: #a8432b;
11
+ --color-destructive-foreground: #faf8f4;
12
+ --color-foreground: #1a1d24;
13
+ --color-muted: #5a5a55;
14
+ --color-muted-foreground: #8a8880;
15
+ --color-primary: #1a1d24;
16
+ --color-primary-foreground: #faf8f4;
17
+ --color-ring: #c8543a;
18
+ --color-secondary: #5a5a55;
19
+ --color-secondary-foreground: #faf8f4;
20
+ --color-surface: #faf8f4;
21
+ --color-surface-foreground: #1a1d24;
22
+ }
23
+ .divide-y-2 { & > * + * { border-block-width: 2px; } }
@@ -0,0 +1,23 @@
1
+ ---
2
+ source: packages/css-engine/crates/aihu-css-core/tests/scoped_snapshot.rs
3
+ expression: "compile_sfc_scoped(&sfc(\"space-y-4\"))"
4
+ ---
5
+ :host {
6
+ --color-accent: #c8543a;
7
+ --color-accent-foreground: #faf8f4;
8
+ --color-background: #faf8f4;
9
+ --color-border: #ddd9d2;
10
+ --color-destructive: #a8432b;
11
+ --color-destructive-foreground: #faf8f4;
12
+ --color-foreground: #1a1d24;
13
+ --color-muted: #5a5a55;
14
+ --color-muted-foreground: #8a8880;
15
+ --color-primary: #1a1d24;
16
+ --color-primary-foreground: #faf8f4;
17
+ --color-ring: #c8543a;
18
+ --color-secondary: #5a5a55;
19
+ --color-secondary-foreground: #faf8f4;
20
+ --color-surface: #faf8f4;
21
+ --color-surface-foreground: #1a1d24;
22
+ }
23
+ .space-y-4 { & > * + * { margin-block-start: 1rem; } }
@@ -0,0 +1,26 @@
1
+ ---
2
+ source: packages/css-engine/crates/aihu-css-core/tests/scoped_snapshot.rs
3
+ assertion_line: 67
4
+ expression: "compile_sfc_scoped(&sfc(\"transition-transform duration-300 hover:scale-105\"))"
5
+ ---
6
+ :host {
7
+ --color-accent: #c8543a;
8
+ --color-accent-foreground: #faf8f4;
9
+ --color-background: #faf8f4;
10
+ --color-border: #ddd9d2;
11
+ --color-destructive: #a8432b;
12
+ --color-destructive-foreground: #faf8f4;
13
+ --color-foreground: #1a1d24;
14
+ --color-muted: #5a5a55;
15
+ --color-muted-foreground: #8a8880;
16
+ --color-primary: #1a1d24;
17
+ --color-primary-foreground: #faf8f4;
18
+ --color-ring: #c8543a;
19
+ --color-secondary: #5a5a55;
20
+ --color-secondary-foreground: #faf8f4;
21
+ --color-surface: #faf8f4;
22
+ --color-surface-foreground: #1a1d24;
23
+ }
24
+ .duration-300 { transition-duration: 300ms; }
25
+ .hover\:scale-105:hover { transform: scale(1.05); }
26
+ .transition-transform { transition-property: transform; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }