@aihu/css-engine 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +122 -0
  3. package/crates/aihu-css-core/Cargo.toml +22 -0
  4. package/crates/aihu-css-core/src/ast.rs +173 -0
  5. package/crates/aihu-css-core/src/bin/main.rs +73 -0
  6. package/crates/aihu-css-core/src/cache.rs +182 -0
  7. package/crates/aihu-css-core/src/emit.rs +236 -0
  8. package/crates/aihu-css-core/src/features/anchor.rs +41 -0
  9. package/crates/aihu-css-core/src/features/mod.rs +33 -0
  10. package/crates/aihu-css-core/src/features/popover.rs +40 -0
  11. package/crates/aihu-css-core/src/features/text_balance.rs +36 -0
  12. package/crates/aihu-css-core/src/features/view_transition.rs +38 -0
  13. package/crates/aihu-css-core/src/lib.rs +67 -0
  14. package/crates/aihu-css-core/src/progressive.rs +200 -0
  15. package/crates/aihu-css-core/src/scanner.rs +235 -0
  16. package/crates/aihu-css-core/src/theme.rs +179 -0
  17. package/crates/aihu-css-core/src/tokens.rs +470 -0
  18. package/crates/aihu-css-core/src/variants.rs +124 -0
  19. package/crates/aihu-css-core/tests/cache.rs +71 -0
  20. package/crates/aihu-css-core/tests/emit.rs +148 -0
  21. package/crates/aihu-css-core/tests/fixtures/button.ast.json +19 -0
  22. package/crates/aihu-css-core/tests/progressive_snapshot.rs +102 -0
  23. package/crates/aihu-css-core/tests/scanner.rs +99 -0
  24. package/crates/aihu-css-core/tests/scoped_snapshot.rs +73 -0
  25. package/crates/aihu-css-core/tests/snapshot.rs +24 -0
  26. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__anchor_snapshot.snap +26 -0
  27. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__popover_snapshot.snap +26 -0
  28. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__text_balance_snapshot.snap +23 -0
  29. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__view_transition_snapshot.snap +25 -0
  30. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__flat_output_for_class_list.snap +6 -0
  31. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_output_for_sfc.snap +25 -0
  32. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_with_authored_style_block.snap +26 -0
  33. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_with_global_style_block.snap +24 -0
  34. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__standard_variants.snap +33 -0
  35. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__theme_default_vs_override.snap +45 -0
  36. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__wc_native_variants.snap +28 -0
  37. package/crates/aihu-css-core/tests/snapshots/snapshot__compiles_basic_class.snap +5 -0
  38. package/crates/aihu-css-core/tests/snapshots/snapshot__compiles_multiple_classes.snap +8 -0
  39. package/crates/aihu-css-core/tests/snapshots/tokens__arbitrary_values.snap +9 -0
  40. package/crates/aihu-css-core/tests/snapshots/tokens__category_borders.snap +8 -0
  41. package/crates/aihu-css-core/tests/snapshots/tokens__category_colors.snap +10 -0
  42. package/crates/aihu-css-core/tests/snapshots/tokens__category_effects.snap +8 -0
  43. package/crates/aihu-css-core/tests/snapshots/tokens__category_layout.snap +12 -0
  44. package/crates/aihu-css-core/tests/snapshots/tokens__category_spacing.snap +11 -0
  45. package/crates/aihu-css-core/tests/snapshots/tokens__category_typography.snap +11 -0
  46. package/crates/aihu-css-core/tests/tokens.rs +79 -0
  47. package/dist/index.d.ts +76 -0
  48. package/dist/index.d.ts.map +1 -0
  49. package/dist/index.js +120 -0
  50. package/dist/index.js.map +1 -0
  51. package/dist/runtime/cn.d.ts +14 -0
  52. package/dist/runtime/cn.d.ts.map +1 -0
  53. package/dist/runtime/cn.js +107 -0
  54. package/dist/runtime/cn.js.map +1 -0
  55. package/dist/runtime/progressive.d.ts +54 -0
  56. package/dist/runtime/progressive.d.ts.map +1 -0
  57. package/dist/runtime/progressive.js +132 -0
  58. package/dist/runtime/progressive.js.map +1 -0
  59. package/package.json +54 -0
  60. package/styles/aihu-default.css +73 -0
  61. package/styles/aihu-graphite.css +71 -0
@@ -0,0 +1,470 @@
1
+ //! Tailwind v4 utility-class → CSS mapping.
2
+ //!
3
+ //! Strategy (Plan 2 Task 3, decision `decision-css-hard-fork-vs-upstream`):
4
+ //! we take *inspiration* from Tailwind v4, not a source merge. The table is a
5
+ //! hybrid:
6
+ //!
7
+ //! - **Regular grids** (spacing, the standard color palette, font-size, etc.)
8
+ //! are generated algorithmically from compact data tables — no wall of
9
+ //! literals, and the spacing scale derives from a single `* 0.25rem` rule.
10
+ //! - **The long tail** (display, flex, position, text-align, …) is a
11
+ //! hand-written `match` of fixed property/value pairs.
12
+ //! - **Arbitrary values** (`bg-[#1a1d24]`, `w-[34ch]`, `text-[14px]`) are
13
+ //! parsed by [`parse_arbitrary`] and emitted verbatim.
14
+ //!
15
+ //! Brand color tokens (`bg-primary`, `text-accent`, …) resolve to
16
+ //! `var(--color-*)` custom properties registered by the `@theme` registry
17
+ //! (`theme.rs`) so authored `@theme` overrides cascade through.
18
+
19
+ /// Emit the **conflict-group map**: `(class-prefix, group-key)` pairs derived
20
+ /// directly from the utility registry's own property maps (`spacing_prop`,
21
+ /// `sizing_prop`, `color_prop`, `arbitrary_prop`). Two utilities conflict (last
22
+ /// wins) when they share a group key — the group key is the CSS property the
23
+ /// prefix controls, so `p-2`/`p-4` (both `padding`) conflict, while `p-2`/`mx-4`
24
+ /// (`padding` vs `margin-inline`) do not.
25
+ ///
26
+ /// This is the single source of truth the engine's build step serializes into
27
+ /// the TS `cn()` conflict map (Plan 3 Task 9) — so the runtime merge map NEVER
28
+ /// drifts from the compile-time utility table. Plus a few fixed-utility groups
29
+ /// (display, position) whose whole-class names share a property.
30
+ pub fn conflict_groups() -> Vec<(&'static str, &'static str)> {
31
+ let mut out: Vec<(&'static str, &'static str)> = Vec::new();
32
+
33
+ // Prefix-based utilities: prefix → its controlled CSS property (the group).
34
+ const SPACING_PREFIXES: &[&str] = &[
35
+ "p", "px", "py", "pt", "pr", "pb", "pl", "m", "mx", "my", "mt", "mr",
36
+ "mb", "ml", "gap", "gap-x", "gap-y",
37
+ ];
38
+ for p in SPACING_PREFIXES {
39
+ if let Some(group) = spacing_prop(p) {
40
+ out.push((p, group));
41
+ }
42
+ }
43
+
44
+ const SIZING_PREFIXES: &[&str] = &["w", "h", "min-w", "max-w", "min-h", "max-h"];
45
+ for p in SIZING_PREFIXES {
46
+ if let Some(group) = sizing_prop(p) {
47
+ out.push((p, group));
48
+ }
49
+ }
50
+
51
+ const COLOR_PREFIXES: &[&str] =
52
+ &["bg", "text", "border", "fill", "stroke", "ring", "outline"];
53
+ for p in COLOR_PREFIXES {
54
+ if let Some(group) = color_prop(p) {
55
+ out.push((p, group));
56
+ }
57
+ }
58
+
59
+ // A handful of non-color/spacing parameterized prefixes with their own group.
60
+ out.push(("z", "z-index"));
61
+ out.push(("opacity", "opacity"));
62
+ out.push(("rounded", "border-radius"));
63
+ out.push(("shadow", "box-shadow"));
64
+ out.push(("font", "font-weight"));
65
+
66
+ out
67
+ }
68
+
69
+ /// Map a single (already variant-stripped) utility class name to its CSS body
70
+ /// (declarations only, no selector). Returns `None` for unknown utilities.
71
+ pub fn utility_to_css(class_name: &str) -> Option<String> {
72
+ // 1. Arbitrary-value bracket syntax: bg-[#1a1d24], w-[34ch], text-[14px].
73
+ if let Some(css) = parse_arbitrary(class_name) {
74
+ return Some(css);
75
+ }
76
+
77
+ // 2. Fixed long-tail utilities (no value parameter).
78
+ if let Some(css) = fixed_utility(class_name) {
79
+ return Some(css.to_string());
80
+ }
81
+
82
+ // 3. Parameterized utilities split on the LAST `-` (prefix + value).
83
+ if let Some(idx) = class_name.rfind('-') {
84
+ let (prefix, value) = (&class_name[..idx], &class_name[idx + 1..]);
85
+ if let Some(css) = parameterized_utility(prefix, value) {
86
+ return Some(css);
87
+ }
88
+ }
89
+ // Whole-token color utilities like `bg-primary` (value = brand token).
90
+ if let Some(css) = brand_color_utility(class_name) {
91
+ return Some(css);
92
+ }
93
+
94
+ None
95
+ }
96
+
97
+ /// Parse `prefix-[value]` arbitrary-value syntax. The bracket content is
98
+ /// emitted verbatim into the mapped CSS property (edge E7).
99
+ pub fn parse_arbitrary(class_name: &str) -> Option<String> {
100
+ let open = class_name.find("-[")?;
101
+ if !class_name.ends_with(']') {
102
+ return None;
103
+ }
104
+ let prefix = &class_name[..open];
105
+ let value = &class_name[open + 2..class_name.len() - 1];
106
+ let prop = arbitrary_prop(prefix)?;
107
+ // Underscores in arbitrary values stand for spaces (Tailwind convention).
108
+ let value = value.replace('_', " ");
109
+ Some(format!("{prop}: {value};"))
110
+ }
111
+
112
+ /// Map an arbitrary-value prefix to its CSS property.
113
+ fn arbitrary_prop(prefix: &str) -> Option<&'static str> {
114
+ Some(match prefix {
115
+ "bg" => "background-color",
116
+ "text" => "color",
117
+ "w" => "width",
118
+ "h" => "height",
119
+ "min-w" => "min-width",
120
+ "max-w" => "max-width",
121
+ "min-h" => "min-height",
122
+ "max-h" => "max-height",
123
+ "p" => "padding",
124
+ "px" => "padding-inline",
125
+ "py" => "padding-block",
126
+ "m" => "margin",
127
+ "mx" => "margin-inline",
128
+ "my" => "margin-block",
129
+ "gap" => "gap",
130
+ "rounded" => "border-radius",
131
+ "border" => "border-width",
132
+ "leading" => "line-height",
133
+ "tracking" => "letter-spacing",
134
+ "z" => "z-index",
135
+ "top" => "top",
136
+ "right" => "right",
137
+ "bottom" => "bottom",
138
+ "left" => "left",
139
+ "inset" => "inset",
140
+ "fill" => "fill",
141
+ "stroke" => "stroke",
142
+ "shadow" => "box-shadow",
143
+ _ => return None,
144
+ })
145
+ }
146
+
147
+ /// Fixed long-tail utilities (display, flex, position, alignment, etc.).
148
+ fn fixed_utility(class_name: &str) -> Option<&'static str> {
149
+ Some(match class_name {
150
+ // Display
151
+ "block" => "display: block;",
152
+ "inline-block" => "display: inline-block;",
153
+ "inline" => "display: inline;",
154
+ "flex" => "display: flex;",
155
+ "inline-flex" => "display: inline-flex;",
156
+ "grid" => "display: grid;",
157
+ "inline-grid" => "display: inline-grid;",
158
+ "hidden" => "display: none;",
159
+ "contents" => "display: contents;",
160
+
161
+ // Flexbox / grid alignment
162
+ "flex-row" => "flex-direction: row;",
163
+ "flex-col" => "flex-direction: column;",
164
+ "flex-wrap" => "flex-wrap: wrap;",
165
+ "flex-nowrap" => "flex-wrap: nowrap;",
166
+ "flex-1" => "flex: 1 1 0%;",
167
+ "flex-auto" => "flex: 1 1 auto;",
168
+ "flex-none" => "flex: none;",
169
+ "items-start" => "align-items: flex-start;",
170
+ "items-center" => "align-items: center;",
171
+ "items-end" => "align-items: flex-end;",
172
+ "items-stretch" => "align-items: stretch;",
173
+ "items-baseline" => "align-items: baseline;",
174
+ "justify-start" => "justify-content: flex-start;",
175
+ "justify-center" => "justify-content: center;",
176
+ "justify-end" => "justify-content: flex-end;",
177
+ "justify-between" => "justify-content: space-between;",
178
+ "justify-around" => "justify-content: space-around;",
179
+ "justify-evenly" => "justify-content: space-evenly;",
180
+
181
+ // Position
182
+ "static" => "position: static;",
183
+ "relative" => "position: relative;",
184
+ "absolute" => "position: absolute;",
185
+ "fixed" => "position: fixed;",
186
+ "sticky" => "position: sticky;",
187
+
188
+ // Overflow
189
+ "overflow-hidden" => "overflow: hidden;",
190
+ "overflow-auto" => "overflow: auto;",
191
+ "overflow-scroll" => "overflow: scroll;",
192
+ "overflow-visible" => "overflow: visible;",
193
+
194
+ // Typography
195
+ "text-left" => "text-align: left;",
196
+ "text-center" => "text-align: center;",
197
+ "text-right" => "text-align: right;",
198
+ "text-justify" => "text-align: justify;",
199
+ "italic" => "font-style: italic;",
200
+ "not-italic" => "font-style: normal;",
201
+ "underline" => "text-decoration-line: underline;",
202
+ "line-through" => "text-decoration-line: line-through;",
203
+ "no-underline" => "text-decoration-line: none;",
204
+ "uppercase" => "text-transform: uppercase;",
205
+ "lowercase" => "text-transform: lowercase;",
206
+ "capitalize" => "text-transform: capitalize;",
207
+ "truncate" => "overflow: hidden; text-overflow: ellipsis; white-space: nowrap;",
208
+ "font-thin" => "font-weight: 100;",
209
+ "font-normal" => "font-weight: 400;",
210
+ "font-medium" => "font-weight: 500;",
211
+ "font-semibold" => "font-weight: 600;",
212
+ "font-bold" => "font-weight: 700;",
213
+ "font-black" => "font-weight: 900;",
214
+
215
+ // Borders / effects
216
+ "border" => "border-width: 1px;",
217
+ "rounded" => "border-radius: 0.25rem;",
218
+ "rounded-none" => "border-radius: 0;",
219
+ "rounded-sm" => "border-radius: 0.125rem;",
220
+ "rounded-md" => "border-radius: 0.375rem;",
221
+ "rounded-lg" => "border-radius: 0.5rem;",
222
+ "rounded-xl" => "border-radius: 0.75rem;",
223
+ "rounded-2xl" => "border-radius: 1rem;",
224
+ "rounded-full" => "border-radius: 9999px;",
225
+ "shadow" => "box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);",
226
+ "shadow-sm" => "box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);",
227
+ "shadow-md" => "box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);",
228
+ "shadow-lg" => "box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1);",
229
+ "shadow-none" => "box-shadow: none;",
230
+
231
+ // Width / height keywords
232
+ "w-full" => "width: 100%;",
233
+ "w-screen" => "width: 100vw;",
234
+ "w-auto" => "width: auto;",
235
+ "h-full" => "height: 100%;",
236
+ "h-screen" => "height: 100vh;",
237
+ "h-auto" => "height: auto;",
238
+
239
+ _ => return None,
240
+ })
241
+ }
242
+
243
+ /// Parameterized utilities split on the last `-`: `p-4`, `text-lg`, `gap-2`,
244
+ /// `text-red-500` (handled via [`palette_color`]), `z-10`, etc.
245
+ fn parameterized_utility(prefix: &str, value: &str) -> Option<String> {
246
+ // Spacing scale: p/px/py/pt/pr/pb/pl, m/mx/my/…, gap, space — value × 0.25rem.
247
+ if let Some(prop) = spacing_prop(prefix) {
248
+ if let Some(rem) = spacing_value(value) {
249
+ return Some(format!("{prop}: {rem};"));
250
+ }
251
+ }
252
+
253
+ // Sizing: w-/h-/min-w-/max-w- with the spacing scale or fractions.
254
+ if let Some(prop) = sizing_prop(prefix) {
255
+ if let Some(v) = sizing_value(value) {
256
+ return Some(format!("{prop}: {v};"));
257
+ }
258
+ }
259
+
260
+ // Font size (with paired line-height, Tailwind defaults).
261
+ if prefix == "text" {
262
+ if let Some(css) = font_size(value) {
263
+ return Some(css.to_string());
264
+ }
265
+ // text-<color>-<shade> falls through to the color path below.
266
+ }
267
+
268
+ // Border radius scale already covered by fixed_utility for named sizes.
269
+
270
+ // z-index.
271
+ if prefix == "z" {
272
+ if value.chars().all(|c| c.is_ascii_digit()) {
273
+ return Some(format!("z-index: {value};"));
274
+ }
275
+ }
276
+
277
+ // opacity.
278
+ if prefix == "opacity" {
279
+ if let Ok(n) = value.parse::<f32>() {
280
+ return Some(format!("opacity: {};", n / 100.0));
281
+ }
282
+ }
283
+
284
+ // Palette colors: bg-red-500, text-slate-700, border-emerald-300.
285
+ if let Some(prop) = color_prop(prefix) {
286
+ // prefix already stripped to the color path; value is the shade only
287
+ // when the full name was `prefix-color-shade`. We handle that in the
288
+ // caller via the full-string brand path; here handle `prefix-keyword`.
289
+ if let Some(color) = named_keyword_color(value) {
290
+ return Some(format!("{prop}: {color};"));
291
+ }
292
+ }
293
+
294
+ None
295
+ }
296
+
297
+ /// Whole-token brand + palette color utilities: `bg-primary`, `text-accent`,
298
+ /// `bg-red-500`. Brand tokens resolve to `var(--color-*)`; palette tokens to
299
+ /// concrete oklch values.
300
+ fn brand_color_utility(class_name: &str) -> Option<String> {
301
+ // Split into <prefix>-<rest> where rest is the color name (may contain `-`).
302
+ let idx = class_name.find('-')?;
303
+ let (prefix, rest) = (&class_name[..idx], &class_name[idx + 1..]);
304
+ let prop = color_prop(prefix)?;
305
+
306
+ // Brand tokens: primary, secondary, accent, surface, muted, foreground, …
307
+ if is_brand_token(rest) {
308
+ return Some(format!("{prop}: var(--color-{rest});"));
309
+ }
310
+ // Palette: red-500, slate-700 → var(--color-red-500) (registered by theme).
311
+ if is_palette_token(rest) {
312
+ return Some(format!("{prop}: var(--color-{rest});"));
313
+ }
314
+ // Bare keyword colors: white/black/transparent/current.
315
+ if let Some(color) = named_keyword_color(rest) {
316
+ return Some(format!("{prop}: {color};"));
317
+ }
318
+ None
319
+ }
320
+
321
+ fn color_prop(prefix: &str) -> Option<&'static str> {
322
+ Some(match prefix {
323
+ "bg" => "background-color",
324
+ "text" => "color",
325
+ "border" => "border-color",
326
+ "fill" => "fill",
327
+ "stroke" => "stroke",
328
+ "ring" => "--tw-ring-color",
329
+ "outline" => "outline-color",
330
+ _ => return None,
331
+ })
332
+ }
333
+
334
+ fn is_brand_token(name: &str) -> bool {
335
+ matches!(
336
+ name,
337
+ "primary"
338
+ | "primary-foreground"
339
+ | "secondary"
340
+ | "secondary-foreground"
341
+ | "accent"
342
+ | "accent-foreground"
343
+ | "surface"
344
+ | "surface-foreground"
345
+ | "background"
346
+ | "foreground"
347
+ | "muted"
348
+ | "muted-foreground"
349
+ | "border"
350
+ | "ring"
351
+ | "destructive"
352
+ | "destructive-foreground"
353
+ )
354
+ }
355
+
356
+ /// `red-500`, `slate-700`, etc. — palette family + numeric shade.
357
+ fn is_palette_token(name: &str) -> bool {
358
+ let Some((family, shade)) = name.rsplit_once('-') else {
359
+ return false;
360
+ };
361
+ const FAMILIES: &[&str] = &[
362
+ "slate", "gray", "zinc", "neutral", "stone", "red", "orange", "amber",
363
+ "yellow", "lime", "green", "emerald", "teal", "cyan", "sky", "blue",
364
+ "indigo", "violet", "purple", "fuchsia", "pink", "rose",
365
+ ];
366
+ FAMILIES.contains(&family)
367
+ && matches!(
368
+ shade,
369
+ "50" | "100" | "200" | "300" | "400" | "500" | "600" | "700" | "800" | "900" | "950"
370
+ )
371
+ }
372
+
373
+ fn named_keyword_color(name: &str) -> Option<&'static str> {
374
+ Some(match name {
375
+ "white" => "#fff",
376
+ "black" => "#000",
377
+ "transparent" => "transparent",
378
+ "current" => "currentColor",
379
+ "inherit" => "inherit",
380
+ _ => return None,
381
+ })
382
+ }
383
+
384
+ fn spacing_prop(prefix: &str) -> Option<&'static str> {
385
+ Some(match prefix {
386
+ "p" => "padding",
387
+ "px" => "padding-inline",
388
+ "py" => "padding-block",
389
+ "pt" => "padding-top",
390
+ "pr" => "padding-right",
391
+ "pb" => "padding-bottom",
392
+ "pl" => "padding-left",
393
+ "m" => "margin",
394
+ "mx" => "margin-inline",
395
+ "my" => "margin-block",
396
+ "mt" => "margin-top",
397
+ "mr" => "margin-right",
398
+ "mb" => "margin-bottom",
399
+ "ml" => "margin-left",
400
+ "gap" => "gap",
401
+ "gap-x" => "column-gap",
402
+ "gap-y" => "row-gap",
403
+ _ => return None,
404
+ })
405
+ }
406
+
407
+ /// Tailwind spacing scale: numeric → `n * 0.25rem`; `px` → `1px`; `0` → `0`.
408
+ fn spacing_value(value: &str) -> Option<String> {
409
+ if value == "px" {
410
+ return Some("1px".to_string());
411
+ }
412
+ if value == "0" {
413
+ return Some("0".to_string());
414
+ }
415
+ // Fractional steps like `0.5`, `1.5`, `2.5`.
416
+ let n: f32 = value.parse().ok()?;
417
+ let rem = n * 0.25;
418
+ Some(format!("{}rem", trim_float(rem)))
419
+ }
420
+
421
+ fn sizing_prop(prefix: &str) -> Option<&'static str> {
422
+ Some(match prefix {
423
+ "w" => "width",
424
+ "h" => "height",
425
+ "min-w" => "min-width",
426
+ "max-w" => "max-width",
427
+ "min-h" => "min-height",
428
+ "max-h" => "max-height",
429
+ _ => return None,
430
+ })
431
+ }
432
+
433
+ fn sizing_value(value: &str) -> Option<String> {
434
+ // Fractions: w-1/2 → 50%.
435
+ if let Some((num, den)) = value.split_once('/') {
436
+ let n: f32 = num.parse().ok()?;
437
+ let d: f32 = den.parse().ok()?;
438
+ if d != 0.0 {
439
+ return Some(format!("{}%", trim_float(n / d * 100.0)));
440
+ }
441
+ }
442
+ // Numeric spacing scale.
443
+ spacing_value(value)
444
+ }
445
+
446
+ /// Font-size scale (size + matched line-height), Tailwind defaults.
447
+ fn font_size(value: &str) -> Option<&'static str> {
448
+ Some(match value {
449
+ "xs" => "font-size: 0.75rem; line-height: 1rem;",
450
+ "sm" => "font-size: 0.875rem; line-height: 1.25rem;",
451
+ "base" => "font-size: 1rem; line-height: 1.5rem;",
452
+ "lg" => "font-size: 1.125rem; line-height: 1.75rem;",
453
+ "xl" => "font-size: 1.25rem; line-height: 1.75rem;",
454
+ "2xl" => "font-size: 1.5rem; line-height: 2rem;",
455
+ "3xl" => "font-size: 1.875rem; line-height: 2.25rem;",
456
+ "4xl" => "font-size: 2.25rem; line-height: 2.5rem;",
457
+ "5xl" => "font-size: 3rem; line-height: 1;",
458
+ _ => return None,
459
+ })
460
+ }
461
+
462
+ /// Trim trailing `.0` from a float for clean CSS (`1.0rem` → `1rem`).
463
+ fn trim_float(n: f32) -> String {
464
+ if n.fract() == 0.0 {
465
+ format!("{}", n as i64)
466
+ } else {
467
+ let s = format!("{n}");
468
+ s
469
+ }
470
+ }
@@ -0,0 +1,124 @@
1
+ //! `variants.rs` — variant-prefix parser + selector/wrapper resolver.
2
+ //!
3
+ //! A utility token may carry one or more `variant:` prefixes
4
+ //! (`host:bg-primary`, `md:hover:bg-primary`, `slotted-img:rounded-lg`,
5
+ //! `part-thumb:bg-accent`, `[&>div]:text-primary`). The scanner stores the
6
+ //! FULL prefixed token; the emitter splits prefixes here at emit time.
7
+ //!
8
+ //! Variants come in two flavours:
9
+ //! - **Selector variants** wrap/append to the base selector (`host:` → `:host`,
10
+ //! `hover:` → `&:hover`, `slotted:` → `::slotted(*)`, `part-x:` → `::part(x)`,
11
+ //! `[&>div]:` → `& > div`).
12
+ //! - **Wrapping variants** wrap the whole rule (`md:` → `@media (...)`).
13
+ //! - **Cascade variants** (`dark:`, `host-context-dark:`) do NOT emit a
14
+ //! `:host-context()` selector (unsupported in Firefox per
15
+ //! `decision-firefox-host-context-workaround`); instead the dark value is
16
+ //! placed behind an inherited custom property the consumer toggles in
17
+ //! `:root`/`.dark`. See [`Variant::is_dark_cascade`].
18
+
19
+ /// One parsed variant prefix.
20
+ #[derive(Debug, Clone, PartialEq, Eq)]
21
+ pub enum Variant {
22
+ // ── WC-native ──────────────────────────────────────────────────────────
23
+ /// `host:` → `:host`.
24
+ Host,
25
+ /// `slotted:` → `::slotted(*)`.
26
+ Slotted,
27
+ /// `slotted-img:` → `::slotted(img)`.
28
+ SlottedTag(String),
29
+ /// `part-<name>:` → `::part(<name>)`.
30
+ Part(String),
31
+ /// `host-context-dark:` — dark cascade (NOT `:host-context()`).
32
+ HostContextDark,
33
+
34
+ // ── standard ─────────────────────────────────────────────────────────────
35
+ /// `hover:`, `focus:`, `focus-visible:`, `active:`, `disabled:` → `&:<pc>`.
36
+ Pseudo(String),
37
+ /// `dark:` — dark cascade (NOT `:host-context()`).
38
+ Dark,
39
+ /// `sm:`/`md:`/`lg:`/`xl:`/`2xl:` → `@media (min-width: …)`.
40
+ Breakpoint(String),
41
+ /// `[&>div]:` arbitrary selector → native nesting (`& > div`).
42
+ ArbitrarySelector(String),
43
+ }
44
+
45
+ impl Variant {
46
+ /// True for the Firefox-safe dark-mode cascade variants. Their value is
47
+ /// emitted behind a custom property the consumer toggles, never a
48
+ /// `:host-context(.dark)` selector.
49
+ pub fn is_dark_cascade(&self) -> bool {
50
+ matches!(self, Variant::Dark | Variant::HostContextDark)
51
+ }
52
+ }
53
+
54
+ /// Split a (possibly multi-prefixed) class token into its ordered variants and
55
+ /// the bare base utility. Unknown prefixes are treated as part of the base
56
+ /// (so an ordinary `bg-red-500` is never mis-split).
57
+ ///
58
+ /// `md:hover:bg-primary` → (`[Breakpoint(md), Pseudo(hover)]`, `"bg-primary"`).
59
+ pub fn split_variants(token: &str) -> (Vec<Variant>, String) {
60
+ let mut variants = Vec::new();
61
+ let mut rest = token;
62
+
63
+ loop {
64
+ // Arbitrary-selector prefix `[...]:` — find the matching `]:`.
65
+ if rest.starts_with('[') {
66
+ if let Some(close) = rest.find("]:") {
67
+ let sel = &rest[1..close];
68
+ variants.push(Variant::ArbitrarySelector(sel.to_string()));
69
+ rest = &rest[close + 2..];
70
+ continue;
71
+ }
72
+ }
73
+
74
+ // Find the next `:` that is NOT inside brackets and is a known prefix.
75
+ let Some(colon) = next_prefix_colon(rest) else {
76
+ break;
77
+ };
78
+ let prefix = &rest[..colon];
79
+ match parse_prefix(prefix) {
80
+ Some(v) => {
81
+ variants.push(v);
82
+ rest = &rest[colon + 1..];
83
+ }
84
+ None => break, // not a recognized variant — the rest is the base.
85
+ }
86
+ }
87
+
88
+ (variants, rest.to_string())
89
+ }
90
+
91
+ /// Find the index of the next top-level `:` (not inside `[...]`).
92
+ fn next_prefix_colon(s: &str) -> Option<usize> {
93
+ let mut depth = 0u32;
94
+ for (i, c) in s.char_indices() {
95
+ match c {
96
+ '[' => depth += 1,
97
+ ']' => depth = depth.saturating_sub(1),
98
+ ':' if depth == 0 => return Some(i),
99
+ _ => {}
100
+ }
101
+ }
102
+ None
103
+ }
104
+
105
+ fn parse_prefix(prefix: &str) -> Option<Variant> {
106
+ Some(match prefix {
107
+ "host" => Variant::Host,
108
+ "host-context-dark" => Variant::HostContextDark,
109
+ "slotted" => Variant::Slotted,
110
+ "dark" => Variant::Dark,
111
+ "hover" | "focus" | "focus-visible" | "active" | "disabled" | "visited"
112
+ | "checked" => Variant::Pseudo(prefix.to_string()),
113
+ "sm" | "md" | "lg" | "xl" | "2xl" => Variant::Breakpoint(prefix.to_string()),
114
+ _ => {
115
+ if let Some(tag) = prefix.strip_prefix("slotted-") {
116
+ Variant::SlottedTag(tag.to_string())
117
+ } else if let Some(name) = prefix.strip_prefix("part-") {
118
+ Variant::Part(name.to_string())
119
+ } else {
120
+ return None;
121
+ }
122
+ }
123
+ })
124
+ }
@@ -0,0 +1,71 @@
1
+ //! Incremental-cache tests (Plan 2 Task 8): a hit returns identical output and
2
+ //! skips recompilation; an AST or theme-version change invalidates the entry.
3
+
4
+ use std::time::Instant;
5
+
6
+ use aihu_css_core::{parse_ast, CssCache, SfcAst};
7
+
8
+ fn sfc(classes: &str) -> SfcAst {
9
+ parse_ast(&format!(
10
+ r#"{{"tag":"X","astVersion":1,"style":null,"meta":{{"name":"X"}},
11
+ "template":[{{"kind":"element","tag":"div","attrs":[
12
+ {{"kind":"static","name":"class","value":"{classes}"}}
13
+ ],"children":[]}}]}}"#
14
+ ))
15
+ .unwrap()
16
+ }
17
+
18
+ #[test]
19
+ fn hit_returns_identical_output_and_skips_recompile() {
20
+ let mut cache = CssCache::new();
21
+ let ast = sfc("bg-primary p-4");
22
+
23
+ let first = cache.compile(&ast, 1);
24
+ assert_eq!(cache.recompiles(), 1, "first compile is a miss");
25
+ assert_eq!(cache.hits(), 0);
26
+
27
+ let second = cache.compile(&ast, 1);
28
+ assert_eq!(second, first, "cache hit returns byte-identical output");
29
+ assert_eq!(cache.recompiles(), 1, "second compile must NOT recompile");
30
+ assert_eq!(cache.hits(), 1, "second compile is a hit");
31
+ }
32
+
33
+ #[test]
34
+ fn ast_change_invalidates_entry() {
35
+ let mut cache = CssCache::new();
36
+ cache.compile(&sfc("p-4"), 1);
37
+ cache.compile(&sfc("p-8"), 1); // different class → different hash
38
+ assert_eq!(cache.recompiles(), 2, "a changed AST recompiles");
39
+ assert_eq!(cache.hits(), 0);
40
+ }
41
+
42
+ #[test]
43
+ fn theme_version_change_invalidates_entry() {
44
+ let mut cache = CssCache::new();
45
+ let ast = sfc("bg-primary");
46
+ cache.compile(&ast, 1);
47
+ cache.compile(&ast, 2); // theme bumped → invalidate
48
+ assert_eq!(cache.recompiles(), 2, "a theme-version bump recompiles");
49
+ }
50
+
51
+ #[test]
52
+ fn cache_hit_is_well_under_30ms() {
53
+ let mut cache = CssCache::new();
54
+ // Warm the cache.
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);
57
+
58
+ // Time 1000 cache hits; each must be far below the 30 ms per-SFC bar.
59
+ let start = Instant::now();
60
+ for _ in 0..1000 {
61
+ let _ = cache.compile(&ast, 1);
62
+ }
63
+ let elapsed = start.elapsed();
64
+ let per_hit = elapsed / 1000;
65
+ assert!(
66
+ per_hit.as_micros() < 30_000,
67
+ "cache hit took {per_hit:?} (>30ms bar)"
68
+ );
69
+ assert_eq!(cache.hits(), 1000);
70
+ assert_eq!(cache.recompiles(), 1, "only the warm-up compiled");
71
+ }