@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.
- package/README.md +27 -22
- 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 +195 -36
- package/crates/aihu-css-core/src/lib.rs +15 -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/theme.rs +14 -0
- package/crates/aihu-css-core/src/tokens.rs +1196 -29
- package/crates/aihu-css-core/src/variants.rs +251 -3
- 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 +284 -17
- 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 +80 -8
- 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 +25 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_divide_y_nested_rule.snap +24 -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 +24 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_transition_and_transform.snap +26 -0
- 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 +526 -7
- 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/dist/runtime/cn.js +13 -0
- package/dist/runtime/cn.js.map +1 -1
- package/package.json +6 -6
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
//! Binary-level error propagation (R-RESULT): an induced emit error must exit
|
|
2
|
+
//! the `aihu-css-compile` binary non-zero AND print the message to stderr.
|
|
3
|
+
//! Cargo sets `CARGO_BIN_EXE_aihu-css-compile` for this integration test.
|
|
4
|
+
|
|
5
|
+
use std::io::Write;
|
|
6
|
+
use std::process::{Command, Stdio};
|
|
7
|
+
|
|
8
|
+
fn run_ast_mode(ast_json: &str) -> std::process::Output {
|
|
9
|
+
let bin = env!("CARGO_BIN_EXE_aihu-css-compile");
|
|
10
|
+
let mut child = Command::new(bin)
|
|
11
|
+
.arg("--ast-json")
|
|
12
|
+
.stdin(Stdio::piped())
|
|
13
|
+
.stdout(Stdio::piped())
|
|
14
|
+
.stderr(Stdio::piped())
|
|
15
|
+
.spawn()
|
|
16
|
+
.expect("spawn aihu-css-compile");
|
|
17
|
+
child
|
|
18
|
+
.stdin
|
|
19
|
+
.as_mut()
|
|
20
|
+
.expect("stdin")
|
|
21
|
+
.write_all(ast_json.as_bytes())
|
|
22
|
+
.expect("write stdin");
|
|
23
|
+
child.wait_with_output().expect("wait")
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
#[test]
|
|
27
|
+
fn malformed_theme_exits_nonzero_with_stderr_message() {
|
|
28
|
+
// `@theme` with no `{` body → CompileError::MalformedTheme.
|
|
29
|
+
let json = r#"{"tag":"X","astVersion":1,
|
|
30
|
+
"style":{"content":"@theme --color-primary: red;","scope":"scoped"},
|
|
31
|
+
"meta":{"name":"X"},"template":null}"#;
|
|
32
|
+
let out = run_ast_mode(json);
|
|
33
|
+
assert!(
|
|
34
|
+
!out.status.success(),
|
|
35
|
+
"binary must exit non-zero on emit error; status={:?}",
|
|
36
|
+
out.status
|
|
37
|
+
);
|
|
38
|
+
let stderr = String::from_utf8_lossy(&out.stderr);
|
|
39
|
+
assert!(
|
|
40
|
+
stderr.contains("aihu-css-compile:") && stderr.contains("malformed @theme"),
|
|
41
|
+
"stderr must carry the actionable message, got: {stderr:?}"
|
|
42
|
+
);
|
|
43
|
+
assert!(
|
|
44
|
+
out.stdout.is_empty(),
|
|
45
|
+
"no CSS should be written on error; stdout={:?}",
|
|
46
|
+
String::from_utf8_lossy(&out.stdout)
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
#[test]
|
|
51
|
+
fn well_formed_input_exits_zero_and_emits_css() {
|
|
52
|
+
let json = r#"{"tag":"X","astVersion":1,
|
|
53
|
+
"style":{"content":"@theme { --color-primary: red; }","scope":"scoped"},
|
|
54
|
+
"meta":{"name":"X"},
|
|
55
|
+
"template":[{"kind":"element","tag":"div","attrs":[
|
|
56
|
+
{"kind":"static","name":"class","value":"bg-primary"}],"children":[]}]}"#;
|
|
57
|
+
let out = run_ast_mode(json);
|
|
58
|
+
assert!(out.status.success(), "well-formed input exits zero");
|
|
59
|
+
let css = String::from_utf8_lossy(&out.stdout);
|
|
60
|
+
assert!(css.contains("background-color: var(--color-primary)"), "{css}");
|
|
61
|
+
}
|
|
@@ -20,11 +20,11 @@ fn hit_returns_identical_output_and_skips_recompile() {
|
|
|
20
20
|
let mut cache = CssCache::new();
|
|
21
21
|
let ast = sfc("bg-primary p-4");
|
|
22
22
|
|
|
23
|
-
let first = cache.compile(&ast, 1);
|
|
23
|
+
let first = cache.compile(&ast, 1).unwrap();
|
|
24
24
|
assert_eq!(cache.recompiles(), 1, "first compile is a miss");
|
|
25
25
|
assert_eq!(cache.hits(), 0);
|
|
26
26
|
|
|
27
|
-
let second = cache.compile(&ast, 1);
|
|
27
|
+
let second = cache.compile(&ast, 1).unwrap();
|
|
28
28
|
assert_eq!(second, first, "cache hit returns byte-identical output");
|
|
29
29
|
assert_eq!(cache.recompiles(), 1, "second compile must NOT recompile");
|
|
30
30
|
assert_eq!(cache.hits(), 1, "second compile is a hit");
|
|
@@ -33,8 +33,8 @@ fn hit_returns_identical_output_and_skips_recompile() {
|
|
|
33
33
|
#[test]
|
|
34
34
|
fn ast_change_invalidates_entry() {
|
|
35
35
|
let mut cache = CssCache::new();
|
|
36
|
-
cache.compile(&sfc("p-4"), 1);
|
|
37
|
-
cache.compile(&sfc("p-8"), 1); // different class → different hash
|
|
36
|
+
cache.compile(&sfc("p-4"), 1).unwrap();
|
|
37
|
+
cache.compile(&sfc("p-8"), 1).unwrap(); // different class → different hash
|
|
38
38
|
assert_eq!(cache.recompiles(), 2, "a changed AST recompiles");
|
|
39
39
|
assert_eq!(cache.hits(), 0);
|
|
40
40
|
}
|
|
@@ -43,8 +43,8 @@ fn ast_change_invalidates_entry() {
|
|
|
43
43
|
fn theme_version_change_invalidates_entry() {
|
|
44
44
|
let mut cache = CssCache::new();
|
|
45
45
|
let ast = sfc("bg-primary");
|
|
46
|
-
cache.compile(&ast, 1);
|
|
47
|
-
cache.compile(&ast, 2); // theme bumped → invalidate
|
|
46
|
+
cache.compile(&ast, 1).unwrap();
|
|
47
|
+
cache.compile(&ast, 2).unwrap(); // theme bumped → invalidate
|
|
48
48
|
assert_eq!(cache.recompiles(), 2, "a theme-version bump recompiles");
|
|
49
49
|
}
|
|
50
50
|
|
|
@@ -53,12 +53,12 @@ fn cache_hit_is_well_under_30ms() {
|
|
|
53
53
|
let mut cache = CssCache::new();
|
|
54
54
|
// Warm the cache.
|
|
55
55
|
let ast = sfc("bg-primary p-4 rounded-lg hover:bg-accent md:p-8 host:text-primary");
|
|
56
|
-
let _ = cache.compile(&ast, 1);
|
|
56
|
+
let _ = cache.compile(&ast, 1).unwrap();
|
|
57
57
|
|
|
58
58
|
// Time 1000 cache hits; each must be far below the 30 ms per-SFC bar.
|
|
59
59
|
let start = Instant::now();
|
|
60
60
|
for _ in 0..1000 {
|
|
61
|
-
let _ = cache.compile(&ast, 1);
|
|
61
|
+
let _ = cache.compile(&ast, 1).unwrap();
|
|
62
62
|
}
|
|
63
63
|
let elapsed = start.elapsed();
|
|
64
64
|
let per_hit = elapsed / 1000;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
//! Scoped-output, variant, and @theme emission tests (Plan 2 Tasks 4–7).
|
|
2
2
|
|
|
3
|
-
use aihu_css_core::{compile_sfc_scoped, parse_ast};
|
|
3
|
+
use aihu_css_core::{compile_sfc_scoped, parse_ast, CompileError};
|
|
4
4
|
|
|
5
5
|
fn ast(json: &str) -> aihu_css_core::SfcAst {
|
|
6
6
|
parse_ast(json).unwrap()
|
|
@@ -21,7 +21,7 @@ fn sfc_with_classes(classes: &str) -> aihu_css_core::SfcAst {
|
|
|
21
21
|
|
|
22
22
|
#[test]
|
|
23
23
|
fn scoped_output_has_no_bare_global_utility_sheet() {
|
|
24
|
-
let css = compile_sfc_scoped(&sfc_with_classes("bg-primary p-4"));
|
|
24
|
+
let css = compile_sfc_scoped(&sfc_with_classes("bg-primary p-4")).unwrap();
|
|
25
25
|
// The utility rules are class selectors inside the shadow <style>; they are
|
|
26
26
|
// scoped by living in the shadow root. There is no separate global sheet.
|
|
27
27
|
assert!(css.contains(".bg-primary"));
|
|
@@ -38,9 +38,13 @@ fn scoped_folds_authored_style_block() {
|
|
|
38
38
|
"meta":{"name":"X"},
|
|
39
39
|
"template":[{"kind":"element","tag":"div","attrs":[
|
|
40
40
|
{"kind":"static","name":"class","value":"p-4"}],"children":[]}]}"#;
|
|
41
|
-
let css = compile_sfc_scoped(&ast(json));
|
|
41
|
+
let css = compile_sfc_scoped(&ast(json)).unwrap();
|
|
42
42
|
assert!(css.contains(".p-4"));
|
|
43
|
-
|
|
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,38 +96,104 @@ fn host_context_dark_uses_cascade_not_host_context() {
|
|
|
90
96
|
|
|
91
97
|
#[test]
|
|
92
98
|
fn hover_variant_appends_pseudo_class() {
|
|
93
|
-
let css = compile_sfc_scoped(&sfc_with_classes("hover:bg-primary"));
|
|
99
|
+
let css = compile_sfc_scoped(&sfc_with_classes("hover:bg-primary")).unwrap();
|
|
94
100
|
assert!(css.contains(":hover"), "hover: → :hover pseudo-class: {css}");
|
|
95
101
|
}
|
|
96
102
|
|
|
97
103
|
#[test]
|
|
98
104
|
fn dark_variant_no_host_context() {
|
|
99
|
-
let css = compile_sfc_scoped(&sfc_with_classes("dark:bg-surface"));
|
|
105
|
+
let css = compile_sfc_scoped(&sfc_with_classes("dark:bg-surface")).unwrap();
|
|
100
106
|
assert!(!css.contains(":host-context("), "dark: must NOT emit :host-context()");
|
|
101
107
|
assert!(css.contains("dark cascade"));
|
|
102
108
|
}
|
|
103
109
|
|
|
104
110
|
#[test]
|
|
105
111
|
fn responsive_md_wraps_media_query() {
|
|
106
|
-
let css = compile_sfc_scoped(&sfc_with_classes("md:p-8"));
|
|
112
|
+
let css = compile_sfc_scoped(&sfc_with_classes("md:p-8")).unwrap();
|
|
107
113
|
assert!(css.contains("@media (min-width:"), "md: → @media: {css}");
|
|
108
114
|
assert!(css.contains("padding: 2rem"));
|
|
109
115
|
}
|
|
110
116
|
|
|
111
117
|
#[test]
|
|
112
118
|
fn arbitrary_selector_variant() {
|
|
113
|
-
let css = compile_sfc_scoped(&sfc_with_classes("[&>div]:text-primary"));
|
|
119
|
+
let css = compile_sfc_scoped(&sfc_with_classes("[&>div]:text-primary")).unwrap();
|
|
114
120
|
assert!(css.contains(">div"), "[&>div]: → child selector: {css}");
|
|
115
121
|
assert!(css.contains("color: var(--color-primary)"));
|
|
116
122
|
}
|
|
117
123
|
|
|
118
124
|
#[test]
|
|
119
125
|
fn stacked_variants_md_hover() {
|
|
120
|
-
let css = compile_sfc_scoped(&sfc_with_classes("md:hover:bg-primary"));
|
|
126
|
+
let css = compile_sfc_scoped(&sfc_with_classes("md:hover:bg-primary")).unwrap();
|
|
121
127
|
assert!(css.contains("@media (min-width:"));
|
|
122
128
|
assert!(css.contains(":hover"));
|
|
123
129
|
}
|
|
124
130
|
|
|
131
|
+
// ── Round 2: group / peer relational variants ────────────────────────────────
|
|
132
|
+
|
|
133
|
+
#[test]
|
|
134
|
+
fn group_hover_emits_ancestor_state_selector() {
|
|
135
|
+
let css = compile_sfc_scoped(&sfc_with_classes("group-hover:bg-primary")).unwrap();
|
|
136
|
+
// Ancestor descendant-combinator: `.group:hover .group-hover\:bg-primary`.
|
|
137
|
+
assert!(
|
|
138
|
+
css.contains(".group:hover .group-hover\\:bg-primary"),
|
|
139
|
+
"group-hover: → `.group:hover <base>` ancestor selector: {css}"
|
|
140
|
+
);
|
|
141
|
+
assert!(css.contains("background-color: var(--color-primary)"));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
#[test]
|
|
145
|
+
fn group_focus_variants_emit_each_state() {
|
|
146
|
+
for (cls, sel) in [
|
|
147
|
+
("group-focus:bg-primary", ".group:focus "),
|
|
148
|
+
("group-focus-visible:bg-primary", ".group:focus-visible "),
|
|
149
|
+
("group-active:bg-primary", ".group:active "),
|
|
150
|
+
("group-disabled:bg-primary", ".group:disabled "),
|
|
151
|
+
] {
|
|
152
|
+
let css = compile_sfc_scoped(&sfc_with_classes(cls)).unwrap();
|
|
153
|
+
assert!(css.contains(sel), "{cls} → `{sel}` prefix: {css}");
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
#[test]
|
|
158
|
+
fn peer_checked_emits_sibling_state_selector() {
|
|
159
|
+
let css = compile_sfc_scoped(&sfc_with_classes("peer-checked:bg-primary")).unwrap();
|
|
160
|
+
// Subsequent-sibling combinator: `.peer:checked ~ .peer-checked\:bg-primary`.
|
|
161
|
+
assert!(
|
|
162
|
+
css.contains(".peer:checked ~ .peer-checked\\:bg-primary"),
|
|
163
|
+
"peer-checked: → `.peer:checked ~ <base>` sibling selector: {css}"
|
|
164
|
+
);
|
|
165
|
+
assert!(css.contains("background-color: var(--color-primary)"));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
#[test]
|
|
169
|
+
fn peer_state_variants_emit_each_state() {
|
|
170
|
+
for (cls, sel) in [
|
|
171
|
+
("peer-hover:bg-primary", ".peer:hover ~ "),
|
|
172
|
+
("peer-focus:bg-primary", ".peer:focus ~ "),
|
|
173
|
+
("peer-focus-visible:bg-primary", ".peer:focus-visible ~ "),
|
|
174
|
+
("peer-disabled:bg-primary", ".peer:disabled ~ "),
|
|
175
|
+
] {
|
|
176
|
+
let css = compile_sfc_scoped(&sfc_with_classes(cls)).unwrap();
|
|
177
|
+
assert!(css.contains(sel), "{cls} → `{sel}` prefix: {css}");
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
#[test]
|
|
182
|
+
fn bare_group_and_peer_markers_emit_empty_rules() {
|
|
183
|
+
let css = compile_sfc_scoped(&sfc_with_classes("group peer")).unwrap();
|
|
184
|
+
// Markers survive as empty-body rules so the relational selectors resolve.
|
|
185
|
+
assert!(css.contains(".group {"), "bare `group` marker kept: {css}");
|
|
186
|
+
assert!(css.contains(".peer {"), "bare `peer` marker kept: {css}");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
#[test]
|
|
190
|
+
fn group_peer_stack_with_responsive() {
|
|
191
|
+
// `md:group-hover:bg-primary` — breakpoint wraps the relational rule.
|
|
192
|
+
let css = compile_sfc_scoped(&sfc_with_classes("md:group-hover:bg-primary")).unwrap();
|
|
193
|
+
assert!(css.contains("@media (min-width:"), "breakpoint wrapper: {css}");
|
|
194
|
+
assert!(css.contains(".group:hover "), "relational selector inside media: {css}");
|
|
195
|
+
}
|
|
196
|
+
|
|
125
197
|
// ── Task 7: @theme directive ─────────────────────────────────────────────────
|
|
126
198
|
|
|
127
199
|
#[test]
|
|
@@ -131,7 +203,7 @@ fn theme_override_registers_token() {
|
|
|
131
203
|
"meta":{"name":"X"},
|
|
132
204
|
"template":[{"kind":"element","tag":"div","attrs":[
|
|
133
205
|
{"kind":"static","name":"class","value":"bg-primary"}],"children":[]}]}"#;
|
|
134
|
-
let css = compile_sfc_scoped(&ast(json));
|
|
206
|
+
let css = compile_sfc_scoped(&ast(json)).unwrap();
|
|
135
207
|
// The override value is registered as a :host token...
|
|
136
208
|
assert!(css.contains("--color-primary: oklch(0.7 0.2 30)"), "@theme override registered: {css}");
|
|
137
209
|
// ...and the utility references it.
|
|
@@ -142,7 +214,202 @@ fn theme_override_registers_token() {
|
|
|
142
214
|
|
|
143
215
|
#[test]
|
|
144
216
|
fn default_aihu_brand_tokens_present() {
|
|
145
|
-
let css = compile_sfc_scoped(&sfc_with_classes("bg-accent"));
|
|
217
|
+
let css = compile_sfc_scoped(&sfc_with_classes("bg-accent")).unwrap();
|
|
146
218
|
// Default accent is the aihu terracotta.
|
|
147
219
|
assert!(css.contains("--color-accent: #c8543a"), "baked aihu brand default present: {css}");
|
|
148
220
|
}
|
|
221
|
+
|
|
222
|
+
// ── Round 2: aria-* / data-* attribute variants ─────────────────────────────
|
|
223
|
+
|
|
224
|
+
#[test]
|
|
225
|
+
fn aria_keyword_variant_emits_true_attr_selector() {
|
|
226
|
+
let css = compile_sfc_scoped(&sfc_with_classes("aria-checked:bg-accent")).unwrap();
|
|
227
|
+
assert!(
|
|
228
|
+
css.contains(r#"[aria-checked="true"]"#),
|
|
229
|
+
"aria-checked: → [aria-checked=\"true\"] selector: {css}"
|
|
230
|
+
);
|
|
231
|
+
assert!(css.contains("background-color: var(--color-accent)"));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
#[test]
|
|
235
|
+
fn aria_expanded_variant() {
|
|
236
|
+
let css = compile_sfc_scoped(&sfc_with_classes("aria-expanded:underline")).unwrap();
|
|
237
|
+
assert!(
|
|
238
|
+
css.contains(r#"[aria-expanded="true"]"#),
|
|
239
|
+
"aria-expanded: → [aria-expanded=\"true\"]: {css}"
|
|
240
|
+
);
|
|
241
|
+
assert!(css.contains("text-decoration-line: underline"));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
#[test]
|
|
245
|
+
fn aria_disabled_selected_pressed_variants() {
|
|
246
|
+
for (cls, attr) in [
|
|
247
|
+
("aria-disabled:opacity-50", r#"[aria-disabled="true"]"#),
|
|
248
|
+
("aria-selected:bg-primary", r#"[aria-selected="true"]"#),
|
|
249
|
+
("aria-pressed:bg-accent", r#"[aria-pressed="true"]"#),
|
|
250
|
+
] {
|
|
251
|
+
let css = compile_sfc_scoped(&sfc_with_classes(cls)).unwrap();
|
|
252
|
+
assert!(css.contains(attr), "{cls} → {attr}: {css}");
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
#[test]
|
|
257
|
+
fn aria_arbitrary_value_variant() {
|
|
258
|
+
let css = compile_sfc_scoped(&sfc_with_classes("aria-[expanded=false]:underline")).unwrap();
|
|
259
|
+
assert!(
|
|
260
|
+
css.contains(r#"[aria-expanded="false"]"#),
|
|
261
|
+
"aria-[expanded=false]: → [aria-expanded=\"false\"]: {css}"
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
#[test]
|
|
266
|
+
fn data_state_variant_emits_attr_selector() {
|
|
267
|
+
let css = compile_sfc_scoped(&sfc_with_classes("data-[state=open]:bg-accent")).unwrap();
|
|
268
|
+
assert!(
|
|
269
|
+
css.contains(r#"[data-state="open"]"#),
|
|
270
|
+
"data-[state=open]: → [data-state=\"open\"] selector: {css}"
|
|
271
|
+
);
|
|
272
|
+
assert!(css.contains("background-color: var(--color-accent)"));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
#[test]
|
|
276
|
+
fn data_keyword_variant_is_presence_selector() {
|
|
277
|
+
// Bare `data-active:` is a presence selector (no implicit ="true").
|
|
278
|
+
let css = compile_sfc_scoped(&sfc_with_classes("data-active:underline")).unwrap();
|
|
279
|
+
assert!(
|
|
280
|
+
css.contains("[data-active]"),
|
|
281
|
+
"data-active: → [data-active] presence selector: {css}"
|
|
282
|
+
);
|
|
283
|
+
assert!(!css.contains(r#"[data-active="true"]"#));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ── Round 2: container queries (@container) ──────────────────────────────────
|
|
287
|
+
|
|
288
|
+
#[test]
|
|
289
|
+
fn container_marker_sets_container_type() {
|
|
290
|
+
let css = compile_sfc_scoped(&sfc_with_classes("@container")).unwrap();
|
|
291
|
+
assert!(
|
|
292
|
+
css.contains("container-type: inline-size"),
|
|
293
|
+
"@container marker → container-type: inline-size: {css}"
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
#[test]
|
|
298
|
+
fn named_container_marker_sets_type_and_name() {
|
|
299
|
+
let css = compile_sfc_scoped(&sfc_with_classes("@container/sidebar")).unwrap();
|
|
300
|
+
assert!(css.contains("container-type: inline-size"), "{css}");
|
|
301
|
+
assert!(
|
|
302
|
+
css.contains("container-name: sidebar"),
|
|
303
|
+
"@container/sidebar → container-name: sidebar: {css}"
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
#[test]
|
|
308
|
+
fn container_md_wraps_rule_in_container_at_rule() {
|
|
309
|
+
let css = compile_sfc_scoped(&sfc_with_classes("@md:flex")).unwrap();
|
|
310
|
+
assert!(
|
|
311
|
+
css.contains("@container (min-width:"),
|
|
312
|
+
"@md: → @container (min-width: ...): {css}"
|
|
313
|
+
);
|
|
314
|
+
// Container scale differs from the viewport breakpoint scale (md = 28rem).
|
|
315
|
+
assert!(css.contains("28rem"), "container @md = 28rem: {css}");
|
|
316
|
+
assert!(css.contains("display: flex"));
|
|
317
|
+
// Must NOT be an @media rule.
|
|
318
|
+
assert!(!css.contains("@media"), "@md: is a container query, not @media: {css}");
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
#[test]
|
|
322
|
+
fn container_sm_lg_scale() {
|
|
323
|
+
let sm = compile_sfc_scoped(&sfc_with_classes("@sm:block")).unwrap();
|
|
324
|
+
assert!(sm.contains("@container (min-width: 24rem)"), "@sm = 24rem: {sm}");
|
|
325
|
+
let lg = compile_sfc_scoped(&sfc_with_classes("@lg:hidden")).unwrap();
|
|
326
|
+
assert!(lg.contains("@container (min-width: 32rem)"), "@lg = 32rem: {lg}");
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
#[test]
|
|
330
|
+
fn container_parent_and_child_pair() {
|
|
331
|
+
// A @container parent + @md:flex child — the proven user-visible pattern.
|
|
332
|
+
let json = r#"{"tag":"X","astVersion":1,"style":null,"meta":{"name":"X"},
|
|
333
|
+
"template":[{"kind":"element","tag":"div","attrs":[
|
|
334
|
+
{"kind":"static","name":"class","value":"@container"}],
|
|
335
|
+
"children":[{"kind":"element","tag":"div","attrs":[
|
|
336
|
+
{"kind":"static","name":"class","value":"@md:flex"}],"children":[]}]}]}"#;
|
|
337
|
+
let css = compile_sfc_scoped(&ast(json)).unwrap();
|
|
338
|
+
assert!(css.contains("container-type: inline-size"), "{css}");
|
|
339
|
+
assert!(css.contains("@container (min-width: 28rem)"), "{css}");
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ── Over-implementation probe: no spurious rules for unknown base utilities ──
|
|
343
|
+
|
|
344
|
+
#[test]
|
|
345
|
+
fn aria_data_without_known_base_emits_nothing() {
|
|
346
|
+
// Unknown base utility behind an aria/data variant must not emit a rule.
|
|
347
|
+
let css = compile_sfc_scoped(&sfc_with_classes(
|
|
348
|
+
"aria-checked:notathing data-[state=open]:alsonope @md:bogus",
|
|
349
|
+
)).unwrap();
|
|
350
|
+
assert!(
|
|
351
|
+
!css.contains("[aria-checked"),
|
|
352
|
+
"no aria selector for unknown base: {css}"
|
|
353
|
+
);
|
|
354
|
+
assert!(
|
|
355
|
+
!css.contains("[data-state"),
|
|
356
|
+
"no data selector for unknown base: {css}"
|
|
357
|
+
);
|
|
358
|
+
assert!(
|
|
359
|
+
!css.contains("@container"),
|
|
360
|
+
"no container at-rule for unknown base: {css}"
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ── R-RESULT: emit returns Result; an induced emit error propagates ──────────
|
|
365
|
+
|
|
366
|
+
#[test]
|
|
367
|
+
fn malformed_theme_block_is_a_compile_error() {
|
|
368
|
+
// An `@theme` opener with no `{` body is malformed; the emit path now
|
|
369
|
+
// hard-errors (CompileError) instead of silently keeping the broken text.
|
|
370
|
+
let json = r#"{"tag":"X","astVersion":1,
|
|
371
|
+
"style":{"content":"@theme --color-primary: red;","scope":"scoped"},
|
|
372
|
+
"meta":{"name":"X"},"template":null}"#;
|
|
373
|
+
let err = compile_sfc_scoped(&ast(json)).unwrap_err();
|
|
374
|
+
assert!(
|
|
375
|
+
matches!(err, CompileError::MalformedTheme { .. }),
|
|
376
|
+
"expected MalformedTheme, got {err:?}"
|
|
377
|
+
);
|
|
378
|
+
assert!(
|
|
379
|
+
err.to_string().contains("malformed @theme"),
|
|
380
|
+
"actionable message: {err}"
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
#[test]
|
|
385
|
+
fn well_formed_theme_block_still_succeeds() {
|
|
386
|
+
// Success path is byte-identical to before the Result conversion.
|
|
387
|
+
let json = r#"{"tag":"X","astVersion":1,
|
|
388
|
+
"style":{"content":"@theme { --color-primary: red; }","scope":"scoped"},
|
|
389
|
+
"meta":{"name":"X"},
|
|
390
|
+
"template":[{"kind":"element","tag":"div","attrs":[
|
|
391
|
+
{"kind":"static","name":"class","value":"bg-primary"}],"children":[]}]}"#;
|
|
392
|
+
let css = compile_sfc_scoped(&ast(json)).unwrap();
|
|
393
|
+
assert!(css.contains("--color-primary: red"), "{css}");
|
|
394
|
+
assert!(css.contains("background-color: var(--color-primary)"), "{css}");
|
|
395
|
+
assert!(!css.contains("@theme"), "directive consumed, not emitted: {css}");
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ── Issue #280: Preflight border-style reset ─────────────────────────────────
|
|
399
|
+
|
|
400
|
+
#[test]
|
|
401
|
+
fn preflight_border_reset_precedes_utilities() {
|
|
402
|
+
// `.unwrap()` added in the PR-1 merge: compile_sfc_scoped now returns Result.
|
|
403
|
+
let css = compile_sfc_scoped(&sfc_with_classes("border")).unwrap();
|
|
404
|
+
// Tailwind v4 Preflight: borders default to solid + zero width.
|
|
405
|
+
assert!(
|
|
406
|
+
css.contains("*, ::before, ::after { border-style: solid; border-width: 0; }"),
|
|
407
|
+
"preflight border reset present: {css}"
|
|
408
|
+
);
|
|
409
|
+
// The `.border` utility still wins by specificity.
|
|
410
|
+
assert!(css.contains(".border { border-width: 1px; }"), "border utility emitted: {css}");
|
|
411
|
+
// Preflight must come before the utility rule so the utility cascades over it.
|
|
412
|
+
let pre = css.find("border-style: solid").unwrap();
|
|
413
|
+
let util = css.find(".border {").unwrap();
|
|
414
|
+
assert!(pre < util, "preflight precedes utility rules: {css}");
|
|
415
|
+
}
|