@aihu/css-engine 0.2.5 → 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.
- package/README.md +23 -18
- package/crates/aihu-css-core/src/emit.rs +87 -8
- package/crates/aihu-css-core/src/lib.rs +5 -0
- package/crates/aihu-css-core/src/theme.rs +14 -0
- package/crates/aihu-css-core/src/tokens.rs +579 -8
- package/crates/aihu-css-core/src/variants.rs +101 -0
- package/crates/aihu-css-core/tests/emit.rs +208 -0
- package/crates/aihu-css-core/tests/scoped_snapshot.rs +35 -1
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_animate_spin_hoists_keyframes.snap +25 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_divide_y_nested_rule.snap +23 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_space_y_nested_rule.snap +23 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_transition_and_transform.snap +26 -0
- package/crates/aihu-css-core/tests/tokens.rs +474 -7
- package/dist/runtime/cn.js +13 -0
- package/dist/runtime/cn.js.map +1 -1
- package/package.json +1 -1
|
@@ -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!(
|
|
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); } }
|
package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_divide_y_nested_rule.snap
ADDED
|
@@ -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; } }
|
package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_transition_and_transform.snap
ADDED
|
@@ -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; }
|