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