@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.
Files changed (52) hide show
  1. package/README.md +27 -22
  2. package/crates/aihu-css-core/src/apply.rs +314 -0
  3. package/crates/aihu-css-core/src/bin/main.rs +8 -7
  4. package/crates/aihu-css-core/src/cache.rs +8 -5
  5. package/crates/aihu-css-core/src/emit.rs +195 -36
  6. package/crates/aihu-css-core/src/lib.rs +15 -2
  7. package/crates/aihu-css-core/src/palette.rs +301 -0
  8. package/crates/aihu-css-core/src/style_parser.rs +587 -0
  9. package/crates/aihu-css-core/src/theme.rs +14 -0
  10. package/crates/aihu-css-core/src/tokens.rs +1196 -29
  11. package/crates/aihu-css-core/src/variants.rs +251 -3
  12. package/crates/aihu-css-core/tests/apply.rs +203 -0
  13. package/crates/aihu-css-core/tests/apply_regression.rs +150 -0
  14. package/crates/aihu-css-core/tests/binary_error.rs +61 -0
  15. package/crates/aihu-css-core/tests/cache.rs +8 -8
  16. package/crates/aihu-css-core/tests/emit.rs +284 -17
  17. package/crates/aihu-css-core/tests/parity.rs +274 -0
  18. package/crates/aihu-css-core/tests/progressive_snapshot.rs +8 -8
  19. package/crates/aihu-css-core/tests/scoped_snapshot.rs +80 -8
  20. package/crates/aihu-css-core/tests/snapshots/apply__apply_inside_nested_rule.snap +11 -0
  21. package/crates/aihu-css-core/tests/snapshots/apply__arbitrary_value_utility_in_apply.snap +8 -0
  22. package/crates/aihu-css-core/tests/snapshots/apply__arbitrary_value_variant_in_apply.snap +10 -0
  23. package/crates/aihu-css-core/tests/snapshots/apply__base_utility_inlines_declarations.snap +9 -0
  24. package/crates/aihu-css-core/tests/snapshots/apply__dark_variant_cascade_in_apply.snap +10 -0
  25. package/crates/aihu-css-core/tests/snapshots/apply__data_attribute_variant.snap +10 -0
  26. package/crates/aihu-css-core/tests/snapshots/apply__multi_token_apply.snap +11 -0
  27. package/crates/aihu-css-core/tests/snapshots/apply__multiple_apply_directives_per_rule.snap +12 -0
  28. package/crates/aihu-css-core/tests/snapshots/apply__responsive_variant_wraps_media.snap +12 -0
  29. package/crates/aihu-css-core/tests/snapshots/apply__single_variant_lifts_to_nested_rule.snap +10 -0
  30. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__anchor_snapshot.snap +1 -0
  31. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__popover_snapshot.snap +1 -0
  32. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__text_balance_snapshot.snap +1 -0
  33. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__view_transition_snapshot.snap +1 -0
  34. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_animate_spin_hoists_keyframes.snap +25 -0
  35. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_divide_y_nested_rule.snap +24 -0
  36. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_output_for_sfc.snap +1 -0
  37. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_space_y_nested_rule.snap +24 -0
  38. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_transition_and_transform.snap +26 -0
  39. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_with_authored_style_block.snap +6 -2
  40. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_with_global_style_block.snap +5 -2
  41. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__standard_variants.snap +1 -0
  42. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__theme_default_vs_override.snap +2 -0
  43. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__wc_native_variants.snap +1 -0
  44. package/crates/aihu-css-core/tests/style_parser.rs +257 -0
  45. package/crates/aihu-css-core/tests/tokens.rs +526 -7
  46. package/dist/index.d.ts +0 -9
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +26 -18
  49. package/dist/index.js.map +1 -1
  50. package/dist/runtime/cn.js +13 -0
  51. package/dist/runtime/cn.js.map +1 -1
  52. package/package.json +6 -6
@@ -32,8 +32,8 @@ pub fn conflict_groups() -> Vec<(&'static str, &'static str)> {
32
32
 
33
33
  // Prefix-based utilities: prefix → its controlled CSS property (the group).
34
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",
35
+ "p", "px", "py", "pt", "pr", "pb", "pl", "m", "mx", "my", "mt", "mr", "mb", "ml", "gap",
36
+ "gap-x", "gap-y",
37
37
  ];
38
38
  for p in SPACING_PREFIXES {
39
39
  if let Some(group) = spacing_prop(p) {
@@ -41,6 +41,48 @@ pub fn conflict_groups() -> Vec<(&'static str, &'static str)> {
41
41
  }
42
42
  }
43
43
 
44
+ // `space-x-*` / `space-y-*` emit nested sibling-margin rules. The group
45
+ // key is the inner declaration so `space-x-2` and `space-x-4` collide
46
+ // (last wins), while `space-x-*` and `space-y-*` do not collide with
47
+ // each other.
48
+ out.push(("space-x", "space-x"));
49
+ out.push(("space-y", "space-y"));
50
+
51
+ // `divide-x-*` / `divide-y-*` emit nested sibling-border rules (reusing the
52
+ // proven `space-*` nested path). The group key is the family so `divide-x-2`
53
+ // and `divide-x-4` collide (last wins), while `divide-x-*` and `divide-y-*`
54
+ // are independent axes that do not collide.
55
+ out.push(("divide-x", "divide-x"));
56
+ out.push(("divide-y", "divide-y"));
57
+
58
+ // Grid templating prefixes — each maps to a single CSS property so the
59
+ // `cn()` last-wins behaviour works per family.
60
+ out.push(("grid-cols", "grid-template-columns"));
61
+ out.push(("grid-rows", "grid-template-rows"));
62
+ out.push(("col-span", "grid-column"));
63
+ out.push(("row-span", "grid-row"));
64
+
65
+ // Position-scale prefixes (top/right/bottom/left/inset[-x|-y]) — each maps
66
+ // to the CSS property it controls so two values of the same family collide
67
+ // (last wins), e.g. `top-4` vs `top-2`. `inset-x`/`inset-y` map to the
68
+ // logical inline/block inset shorthands, distinct from the all-sides
69
+ // `inset`. The negative forms (`-top-4`) share the same group as the
70
+ // positive forms because they set the same property.
71
+ const POSITION_PREFIXES: &[&str] = &[
72
+ "top", "right", "bottom", "left", "inset", "inset-x", "inset-y",
73
+ ];
74
+ for p in POSITION_PREFIXES {
75
+ if let Some(group) = position_prop(p) {
76
+ out.push((p, group));
77
+ }
78
+ }
79
+
80
+ // Named typography scales: `leading-*` (line-height) and `tracking-*`
81
+ // (letter-spacing). Registering the prefix means `leading-tight` and
82
+ // `leading-loose` collide last-wins.
83
+ out.push(("leading", "line-height"));
84
+ out.push(("tracking", "letter-spacing"));
85
+
44
86
  const SIZING_PREFIXES: &[&str] = &["w", "h", "min-w", "max-w", "min-h", "max-h"];
45
87
  for p in SIZING_PREFIXES {
46
88
  if let Some(group) = sizing_prop(p) {
@@ -48,14 +90,22 @@ pub fn conflict_groups() -> Vec<(&'static str, &'static str)> {
48
90
  }
49
91
  }
50
92
 
51
- const COLOR_PREFIXES: &[&str] =
52
- &["bg", "text", "border", "fill", "stroke", "ring", "outline"];
93
+ const COLOR_PREFIXES: &[&str] = &["bg", "text", "border", "fill", "stroke", "ring", "outline"];
53
94
  for p in COLOR_PREFIXES {
54
95
  if let Some(group) = color_prop(p) {
55
96
  out.push((p, group));
56
97
  }
57
98
  }
58
99
 
100
+ // Ring WIDTH (`ring-{n}`) and ring-OFFSET width (`ring-offset-{n}`) both
101
+ // share the `ring` class prefix (the runtime `cn()` `groupKey` splits on the
102
+ // FIRST dash, so `ring-2`, `ring-blue-500`, and `ring-offset-2` all key to
103
+ // prefix `ring`). The `ring` → `--tw-ring-color` entry pushed by the color
104
+ // loop above ALREADY makes every `ring*` utility last-wins as one group, so
105
+ // we deliberately do NOT push a second `("ring", …)` entry here — a duplicate
106
+ // prefix key would collide in the generated `cn()` map. `ring-2` then
107
+ // `ring-4` collapses to `ring-4`, which is the desired last-wins behaviour.
108
+
59
109
  // A handful of non-color/spacing parameterized prefixes with their own group.
60
110
  out.push(("z", "z-index"));
61
111
  out.push(("opacity", "opacity"));
@@ -63,22 +113,86 @@ pub fn conflict_groups() -> Vec<(&'static str, &'static str)> {
63
113
  out.push(("shadow", "box-shadow"));
64
114
  out.push(("font", "font-weight"));
65
115
 
116
+ // Motion families (Round 2: tailwind-support `motion` track). Every motion
117
+ // transform utility emits a single `transform:` declaration, so the engine
118
+ // resolves them via the CSS cascade (last declared wins). For `cn()`
119
+ // last-wins we register one group key per family — translate/rotate/scale
120
+ // dedupe within a family while leaving sibling families independent
121
+ // (matching Tailwind's mental model). Combining families on one element
122
+ // requires an arbitrary value (`transform-[...]`), see the docs note.
123
+ out.push(("translate-x", "translate"));
124
+ out.push(("translate-y", "translate"));
125
+ out.push(("rotate", "rotate"));
126
+ out.push(("scale", "scale"));
127
+ out.push(("scale-x", "scale"));
128
+ out.push(("scale-y", "scale"));
129
+ // Transition / timing / animation each control a single property.
130
+ out.push(("duration", "transition-duration"));
131
+
66
132
  out
67
133
  }
68
134
 
69
135
  /// Map a single (already variant-stripped) utility class name to its CSS body
70
136
  /// (declarations only, no selector). Returns `None` for unknown utilities.
71
137
  pub fn utility_to_css(class_name: &str) -> Option<String> {
138
+ // 0. Opacity modifier: `bg-accent/15`, `text-primary/50`, `border-red-500/30`.
139
+ // A trailing `/NN` (0..=100) applies the opacity to a COLOR utility via
140
+ // `color-mix(in oklab, <color> NN%, transparent)`. Only color-property
141
+ // utilities opt in, so sizing fractions (`w-1/2`) are left untouched.
142
+ if let Some(css) = parse_color_opacity(class_name) {
143
+ return Some(css);
144
+ }
145
+
72
146
  // 1. Arbitrary-value bracket syntax: bg-[#1a1d24], w-[34ch], text-[14px].
73
147
  if let Some(css) = parse_arbitrary(class_name) {
74
148
  return Some(css);
75
149
  }
76
150
 
151
+ // 1a. CSS-variable shorthand: `bg-(--brand)`, `text-(--muted-fg)`,
152
+ // `border-(--border)`. Tailwind v4 desugars `x-(--v)` to `x-[var(--v)]`
153
+ // but keeps the PREFIX's property typing (so `border-(--c)` is a COLOR,
154
+ // not a width). Routed before the generic `-` split so the parens aren't
155
+ // mistaken for a value segment.
156
+ if let Some(css) = parse_var_shorthand(class_name) {
157
+ return Some(css);
158
+ }
159
+
160
+ // 1c. Gradient stops: `from-amber-200`, `via-primary`, `to-(--accent)`.
161
+ if let Some(css) = parse_gradient_stop(class_name) {
162
+ return Some(css);
163
+ }
164
+
165
+ // 1b. Named container context: `@container/<name>` declares a *named* query
166
+ // container so descendant `@<bp>/<name>:` variants can target it. Emits both
167
+ // the container-type and the container-name. (The bare `@container` form is a
168
+ // fixed utility below.)
169
+ if let Some(name) = class_name.strip_prefix("@container/") {
170
+ if !name.is_empty()
171
+ && name
172
+ .bytes()
173
+ .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
174
+ {
175
+ return Some(format!(
176
+ "container-type: inline-size; container-name: {name};"
177
+ ));
178
+ }
179
+ }
180
+
77
181
  // 2. Fixed long-tail utilities (no value parameter).
78
182
  if let Some(css) = fixed_utility(class_name) {
79
183
  return Some(css.to_string());
80
184
  }
81
185
 
186
+ // 3a. Negative motion utilities: `-translate-x-2`, `-rotate-45`. The leading
187
+ // `-` is not part of any prefix, so we strip it, compile the positive form,
188
+ // and negate the emitted numeric value. Only the negatable motion families
189
+ // (`translate-*`, `rotate-*`) opt in via `negate_motion`.
190
+ if let Some(rest) = class_name.strip_prefix('-') {
191
+ if let Some(css) = negate_motion(rest) {
192
+ return Some(css);
193
+ }
194
+ }
195
+
82
196
  // 3. Parameterized utilities split on the LAST `-` (prefix + value).
83
197
  if let Some(idx) = class_name.rfind('-') {
84
198
  let (prefix, value) = (&class_name[..idx], &class_name[idx + 1..]);
@@ -94,21 +208,151 @@ pub fn utility_to_css(class_name: &str) -> Option<String> {
94
208
  None
95
209
  }
96
210
 
211
+ /// Compile the negative form of a motion transform utility. `rest` is the class
212
+ /// name with its leading `-` already stripped (e.g. `translate-x-2`, `rotate-45`).
213
+ /// Returns the same `transform:` declaration with a negated value. Only
214
+ /// `translate-x/y-*` and `rotate-*` are negatable (Tailwind's `-` prefix set).
215
+ fn negate_motion(rest: &str) -> Option<String> {
216
+ let idx = rest.rfind('-')?;
217
+ let (prefix, value) = (&rest[..idx], &rest[idx + 1..]);
218
+ match prefix {
219
+ "translate-x" => {
220
+ let len = translate_length(value)?;
221
+ Some(format!("transform: translateX(-{len});"))
222
+ }
223
+ "translate-y" => {
224
+ let len = translate_length(value)?;
225
+ Some(format!("transform: translateY(-{len});"))
226
+ }
227
+ "rotate" => {
228
+ let deg = positive_int(value)?;
229
+ Some(format!("transform: rotate(-{deg}deg);"))
230
+ }
231
+ _ => None,
232
+ }
233
+ }
234
+
235
+ /// Parse a trailing `/NN` opacity modifier on a color utility. `bg-accent/15`
236
+ /// resolves the base (`bg-accent` → `background-color: var(--color-accent);`)
237
+ /// and rewrites the value into a `color-mix(in oklab, <color> NN%, transparent)`
238
+ /// (Tailwind v4 parity). Returns `None` when there is no `/NN`, when the percent
239
+ /// is out of range, or when the base does not resolve to a single color
240
+ /// declaration (so sizing fractions like `w-1/2` are never touched).
241
+ pub fn parse_color_opacity(class_name: &str) -> Option<String> {
242
+ let (base, pct) = class_name.rsplit_once('/')?;
243
+ // The base must not itself contain a `/` (avoid `a/b/c`); pct is 0..=100.
244
+ if base.contains('/') {
245
+ return None;
246
+ }
247
+ if pct.is_empty() || !pct.bytes().all(|b| b.is_ascii_digit()) {
248
+ return None;
249
+ }
250
+ let n: u32 = pct.parse().ok()?;
251
+ if n > 100 {
252
+ return None;
253
+ }
254
+ // Resolve the base through the color path only. The prefix (split on the
255
+ // FIRST `-`) must map to a color property, and the resolved body must be a
256
+ // single `prop: <color>;` declaration.
257
+ let idx = base.find('-')?;
258
+ let prefix = &base[..idx];
259
+ let prop = color_prop(prefix)?;
260
+ let body = utility_to_css(base)?;
261
+ let decl = format!("{prop}: ");
262
+ let value = body.strip_prefix(&decl)?.strip_suffix(';')?;
263
+ // Bail if the value is itself a multi-declaration body (defensive).
264
+ if value.contains(';') {
265
+ return None;
266
+ }
267
+ Some(format!(
268
+ "{prop}: color-mix(in oklab, {value} {n}%, transparent);"
269
+ ))
270
+ }
271
+
97
272
  /// Parse `prefix-[value]` arbitrary-value syntax. The bracket content is
98
273
  /// emitted verbatim into the mapped CSS property (edge E7).
274
+ ///
275
+ /// Two refinements over a flat prefix→prop map:
276
+ /// - **Data-type hints** — `border-[length:2px]` / `text-[color:var(--x)]`
277
+ /// force the interpretation Tailwind v4 supports for ambiguous prefixes.
278
+ /// - **Color/length disambiguation** — for `border`/`outline`/`ring`/`divide`,
279
+ /// a value that *looks like a color* maps to the color property; otherwise to
280
+ /// the width property. (Previously `border-[…]` always meant width, so
281
+ /// `border-[var(--c)]` silently produced an invalid `border-width`.)
99
282
  pub fn parse_arbitrary(class_name: &str) -> Option<String> {
100
283
  let open = class_name.find("-[")?;
101
284
  if !class_name.ends_with(']') {
102
285
  return None;
103
286
  }
104
287
  let prefix = &class_name[..open];
105
- let value = &class_name[open + 2..class_name.len() - 1];
106
- let prop = arbitrary_prop(prefix)?;
288
+ let raw = &class_name[open + 2..class_name.len() - 1];
289
+ // Optional `<type>:` data-type hint.
290
+ let (hint, body) = match raw.split_once(':') {
291
+ Some((h, v))
292
+ if matches!(
293
+ h,
294
+ "color" | "length" | "angle" | "url" | "number" | "percentage" | "image"
295
+ ) =>
296
+ {
297
+ (Some(h), v)
298
+ }
299
+ _ => (None, raw),
300
+ };
107
301
  // Underscores in arbitrary values stand for spaces (Tailwind convention).
108
- let value = value.replace('_', " ");
302
+ let value = body.replace('_', " ");
303
+
304
+ // Multi-declaration special cases.
305
+ match prefix {
306
+ "size" => return Some(format!("width: {value}; height: {value};")),
307
+ "mask" | "mask-image" => {
308
+ return Some(format!("-webkit-mask-image: {value}; mask-image: {value};"));
309
+ }
310
+ _ => {}
311
+ }
312
+
313
+ let is_color = hint == Some("color") || (hint.is_none() && looks_like_color(&value));
314
+ let prop = match prefix {
315
+ "border" => {
316
+ if is_color {
317
+ "border-color"
318
+ } else {
319
+ "border-width"
320
+ }
321
+ }
322
+ "outline" => {
323
+ if is_color {
324
+ "outline-color"
325
+ } else {
326
+ "outline-width"
327
+ }
328
+ }
329
+ "ring" => {
330
+ if is_color {
331
+ "--tw-ring-color"
332
+ } else {
333
+ "--tw-ring-width"
334
+ }
335
+ }
336
+ _ => arbitrary_prop(prefix)?,
337
+ };
109
338
  Some(format!("{prop}: {value};"))
110
339
  }
111
340
 
341
+ /// Heuristic: does an arbitrary value denote a color (vs. a length)? Covers
342
+ /// hex, the CSS color functions, `var(--…)` (assumed a color in a color slot),
343
+ /// and the bare CSS named colors the engine already knows.
344
+ fn looks_like_color(v: &str) -> bool {
345
+ let v = v.trim();
346
+ v.starts_with('#')
347
+ || v.starts_with("var(")
348
+ || v.starts_with("rgb")
349
+ || v.starts_with("hsl")
350
+ || v.starts_with("oklch")
351
+ || v.starts_with("oklab")
352
+ || v.starts_with("color(")
353
+ || matches!(v, "transparent" | "currentColor" | "white" | "black")
354
+ }
355
+
112
356
  /// Map an arbitrary-value prefix to its CSS property.
113
357
  fn arbitrary_prop(prefix: &str) -> Option<&'static str> {
114
358
  Some(match prefix {
@@ -116,6 +360,7 @@ fn arbitrary_prop(prefix: &str) -> Option<&'static str> {
116
360
  "text" => "color",
117
361
  "w" => "width",
118
362
  "h" => "height",
363
+ "size" => "width",
119
364
  "min-w" => "min-width",
120
365
  "max-w" => "max-width",
121
366
  "min-h" => "min-height",
@@ -123,23 +368,180 @@ fn arbitrary_prop(prefix: &str) -> Option<&'static str> {
123
368
  "p" => "padding",
124
369
  "px" => "padding-inline",
125
370
  "py" => "padding-block",
371
+ "pt" => "padding-top",
372
+ "pr" => "padding-right",
373
+ "pb" => "padding-bottom",
374
+ "pl" => "padding-left",
126
375
  "m" => "margin",
127
376
  "mx" => "margin-inline",
128
377
  "my" => "margin-block",
378
+ "mt" => "margin-top",
379
+ "mr" => "margin-right",
380
+ "mb" => "margin-bottom",
381
+ "ml" => "margin-left",
129
382
  "gap" => "gap",
383
+ "gap-x" => "column-gap",
384
+ "gap-y" => "row-gap",
130
385
  "rounded" => "border-radius",
131
386
  "border" => "border-width",
387
+ "border-t" => "border-top-width",
388
+ "border-r" => "border-right-width",
389
+ "border-b" => "border-bottom-width",
390
+ "border-l" => "border-left-width",
391
+ "grid-cols" => "grid-template-columns",
392
+ "grid-rows" => "grid-template-rows",
393
+ "col-span" => "grid-column",
394
+ "row-span" => "grid-row",
132
395
  "leading" => "line-height",
133
396
  "tracking" => "letter-spacing",
397
+ "aspect" => "aspect-ratio",
398
+ "basis" => "flex-basis",
134
399
  "z" => "z-index",
400
+ "order" => "order",
135
401
  "top" => "top",
136
402
  "right" => "right",
137
403
  "bottom" => "bottom",
138
404
  "left" => "left",
139
405
  "inset" => "inset",
406
+ "translate-x" => "translate",
140
407
  "fill" => "fill",
141
408
  "stroke" => "stroke",
142
409
  "shadow" => "box-shadow",
410
+ "opacity" => "opacity",
411
+ "blur" => "filter",
412
+ "duration" => "transition-duration",
413
+ "transition" => "transition-property",
414
+ "font" => "font-family",
415
+ "leading-trim" => "line-height",
416
+ "outline-offset" => "outline-offset",
417
+ _ => return None,
418
+ })
419
+ }
420
+
421
+ /// Parse the Tailwind v4 CSS-variable shorthand `prefix-(--token)`. Desugars to
422
+ /// `prefix-[var(--token)]`, but resolves the property through the *prefix's*
423
+ /// type: color prefixes (`bg`/`text`/`border`/`fill`/`stroke`/`ring`/`outline`)
424
+ /// → the color property, `divide` → the sibling border-color recipe, gradient
425
+ /// prefixes → a gradient stop, everything else → the length/box property. An
426
+ /// optional trailing `/NN` is handled upstream by `parse_color_opacity` (which
427
+ /// re-resolves the base through here), so this function never sees the opacity.
428
+ pub fn parse_var_shorthand(class_name: &str) -> Option<String> {
429
+ let open = class_name.find("-(")?;
430
+ if !class_name.ends_with(')') {
431
+ return None;
432
+ }
433
+ let prefix = &class_name[..open];
434
+ let inner = &class_name[open + 2..class_name.len() - 1];
435
+ if !inner.starts_with("--") {
436
+ return None;
437
+ }
438
+ let value = format!("var({inner})");
439
+ if let Some(prop) = color_prop(prefix) {
440
+ return Some(format!("{prop}: {value};"));
441
+ }
442
+ if prefix == "divide" {
443
+ return Some(format!("& > * + * {{ border-color: {value}; }}"));
444
+ }
445
+ if matches!(prefix, "from" | "via" | "to") {
446
+ return gradient_stop(prefix, &value);
447
+ }
448
+ if let Some(prop) = arbitrary_prop(prefix) {
449
+ return Some(format!("{prop}: {value};"));
450
+ }
451
+ None
452
+ }
453
+
454
+ /// Parse a gradient color-stop utility: `from-<color>`, `via-<color>`,
455
+ /// `to-<color>`. The color may be a brand/palette token, a bare keyword, an
456
+ /// arbitrary `[…]` value, or a `(--var)` shorthand.
457
+ fn parse_gradient_stop(class_name: &str) -> Option<String> {
458
+ let (prefix, rest) = class_name.split_once('-')?;
459
+ if !matches!(prefix, "from" | "via" | "to") {
460
+ return None;
461
+ }
462
+ let color = if let Some(inner) = rest.strip_prefix('[').and_then(|r| r.strip_suffix(']')) {
463
+ inner.replace('_', " ")
464
+ } else if let Some(inner) = rest.strip_prefix('(').and_then(|r| r.strip_suffix(')')) {
465
+ if !inner.starts_with("--") {
466
+ return None;
467
+ }
468
+ format!("var({inner})")
469
+ } else {
470
+ resolve_color(rest)?
471
+ };
472
+ gradient_stop(prefix, &color)
473
+ }
474
+
475
+ /// Emit the `--tw-gradient-*` custom properties for a gradient color stop using
476
+ /// Tailwind's stop recipe. `bg-linear-to-*` / `bg-gradient-to-*` consume
477
+ /// `var(--tw-gradient-stops)`.
478
+ fn gradient_stop(prefix: &str, color: &str) -> Option<String> {
479
+ Some(match prefix {
480
+ "from" => format!(
481
+ "--tw-gradient-from: {color}; --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to, rgb(0 0 0 / 0));"
482
+ ),
483
+ "via" => format!(
484
+ "--tw-gradient-stops: var(--tw-gradient-from), {color}, var(--tw-gradient-to, rgb(0 0 0 / 0));"
485
+ ),
486
+ "to" => format!("--tw-gradient-to: {color};"),
487
+ _ => return None,
488
+ })
489
+ }
490
+
491
+ /// Scan emitted CSS for `var(--color-<family>-<shade>)` palette references and
492
+ /// register each one the theme does not already define with its Tailwind v4
493
+ /// oklch value. Palette utilities compile to `var(--color-*)` refs; Tailwind
494
+ /// ships the palette in its default theme, so the scoped emitter calls this to
495
+ /// make the referenced colors resolve at `:host` (without bloating every shadow
496
+ /// root with all 286 entries — only the ones a component actually uses).
497
+ pub fn register_used_palette(css: &str, theme: &mut crate::theme::ThemeRegistry) {
498
+ const NEEDLE: &str = "var(--color-";
499
+ let mut decls = String::new();
500
+ let mut seen = std::collections::BTreeSet::new();
501
+ let mut rest = css;
502
+ while let Some(pos) = rest.find(NEEDLE) {
503
+ let after = &rest[pos + NEEDLE.len()..];
504
+ let Some(close) = after.find(')') else { break };
505
+ let token = &after[..close];
506
+ rest = &after[close..];
507
+ // Only `<family>-<shade>` palette tokens (brand tokens like `primary`
508
+ // are already registered and have no oklch table entry).
509
+ if !seen.insert(token.to_string()) {
510
+ continue;
511
+ }
512
+ let name = format!("--color-{token}");
513
+ if theme.get(&name).is_none() {
514
+ if let Some(value) = crate::palette::oklch(token) {
515
+ decls.push_str(&format!("{name}: {value};\n"));
516
+ }
517
+ }
518
+ }
519
+ if !decls.is_empty() {
520
+ theme.apply_theme_block(&decls);
521
+ }
522
+ }
523
+
524
+ /// Resolve a color name (brand token, palette token, or bare keyword) to its
525
+ /// CSS value. Brand + palette tokens resolve to `var(--color-*)`.
526
+ fn resolve_color(name: &str) -> Option<String> {
527
+ if is_brand_token(name) || is_palette_token(name) {
528
+ return Some(format!("var(--color-{name})"));
529
+ }
530
+ named_keyword_color(name).map(str::to_string)
531
+ }
532
+
533
+ /// Map a gradient direction suffix (`bg-linear-to-<dir>`) to its `linear-gradient`
534
+ /// direction keyword.
535
+ fn gradient_dir(value: &str) -> Option<&'static str> {
536
+ Some(match value {
537
+ "t" => "to top",
538
+ "tr" => "to top right",
539
+ "r" => "to right",
540
+ "br" => "to bottom right",
541
+ "b" => "to bottom",
542
+ "bl" => "to bottom left",
543
+ "l" => "to left",
544
+ "tl" => "to top left",
143
545
  _ => return None,
144
546
  })
145
547
  }
@@ -158,6 +560,12 @@ fn fixed_utility(class_name: &str) -> Option<&'static str> {
158
560
  "hidden" => "display: none;",
159
561
  "contents" => "display: contents;",
160
562
 
563
+ // Container-query context. `@container` (and the named `@container/<name>`
564
+ // form, normalized in `utility_to_css`) marks an element as a query
565
+ // container so descendant `@sm:`/`@md:`/`@lg:` variants resolve against
566
+ // its inline size. Tailwind's default container-type is `inline-size`.
567
+ "@container" => "container-type: inline-size;",
568
+
161
569
  // Flexbox / grid alignment
162
570
  "flex-row" => "flex-direction: row;",
163
571
  "flex-col" => "flex-direction: column;",
@@ -178,6 +586,14 @@ fn fixed_utility(class_name: &str) -> Option<&'static str> {
178
586
  "justify-around" => "justify-content: space-around;",
179
587
  "justify-evenly" => "justify-content: space-evenly;",
180
588
 
589
+ // Interactivity
590
+ "select-none" => "user-select: none;",
591
+ "select-text" => "user-select: text;",
592
+ "select-all" => "user-select: all;",
593
+ "select-auto" => "user-select: auto;",
594
+ "pointer-events-none" => "pointer-events: none;",
595
+ "pointer-events-auto" => "pointer-events: auto;",
596
+
181
597
  // Position
182
598
  "static" => "position: static;",
183
599
  "relative" => "position: relative;",
@@ -211,6 +627,11 @@ fn fixed_utility(class_name: &str) -> Option<&'static str> {
211
627
  "font-semibold" => "font-weight: 600;",
212
628
  "font-bold" => "font-weight: 700;",
213
629
  "font-black" => "font-weight: 900;",
630
+ // Font-family families. Resolved as FIXED utilities (not parameterized)
631
+ // so they don't muddy the `("font","font-weight")` conflict group key.
632
+ // The `--font-sans`/`--font-mono` tokens are shipped by both packs.
633
+ "font-sans" => "font-family: var(--font-sans);",
634
+ "font-mono" => "font-family: var(--font-mono);",
214
635
 
215
636
  // Borders / effects
216
637
  "border" => "border-width: 1px;",
@@ -228,6 +649,65 @@ fn fixed_utility(class_name: &str) -> Option<&'static str> {
228
649
  "shadow-lg" => "box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1);",
229
650
  "shadow-none" => "box-shadow: none;",
230
651
 
652
+ // Border widths (fixed N values). Directional + arbitrary `border-N`
653
+ // arms are also wired via `parameterized_utility` below.
654
+ "border-0" => "border-width: 0;",
655
+ "border-2" => "border-width: 2px;",
656
+ "border-4" => "border-width: 4px;",
657
+ "border-8" => "border-width: 8px;",
658
+ "border-x-0" => "border-inline-width: 0;",
659
+ "border-x-2" => "border-inline-width: 2px;",
660
+ "border-x-4" => "border-inline-width: 4px;",
661
+ "border-x-8" => "border-inline-width: 8px;",
662
+ "border-y-0" => "border-block-width: 0;",
663
+ "border-y-2" => "border-block-width: 2px;",
664
+ "border-y-4" => "border-block-width: 4px;",
665
+ "border-y-8" => "border-block-width: 8px;",
666
+
667
+ // `divide-x` / `divide-y` — sibling borders between adjacent children.
668
+ // Bare forms default to 1px (Tailwind parity). These reuse the proven
669
+ // `space-*` nested-rule path: a `& > * + *` block survives the scoped
670
+ // CSS-nesting emission path and minifies to `.divide-x>*+*{...}`.
671
+ // Numeric forms (`divide-x-2`, …) and `-reverse` are handled in
672
+ // `parameterized_utility` (split on the last `-`).
673
+ "divide-x" => "& > * + * { border-inline-width: 1px; }",
674
+ "divide-y" => "& > * + * { border-block-width: 1px; }",
675
+ // Bare directional borders default to 1px (Tailwind parity). The numeric
676
+ // forms (`border-t-2`, …) follow below. With the Preflight reset
677
+ // (`*,::before,::after { border-style: solid }`) these render visibly.
678
+ "border-t" => "border-top-width: 1px;",
679
+ "border-r" => "border-right-width: 1px;",
680
+ "border-b" => "border-bottom-width: 1px;",
681
+ "border-l" => "border-left-width: 1px;",
682
+ "border-x" => "border-inline-width: 1px;",
683
+ "border-y" => "border-block-width: 1px;",
684
+ "border-t-0" => "border-top-width: 0;",
685
+ "border-t-2" => "border-top-width: 2px;",
686
+ "border-t-4" => "border-top-width: 4px;",
687
+ "border-t-8" => "border-top-width: 8px;",
688
+ "border-r-0" => "border-right-width: 0;",
689
+ "border-r-2" => "border-right-width: 2px;",
690
+ "border-r-4" => "border-right-width: 4px;",
691
+ "border-r-8" => "border-right-width: 8px;",
692
+ "border-b-0" => "border-bottom-width: 0;",
693
+ "border-b-2" => "border-bottom-width: 2px;",
694
+ "border-b-4" => "border-bottom-width: 4px;",
695
+ "border-b-8" => "border-bottom-width: 8px;",
696
+ "border-l-0" => "border-left-width: 0;",
697
+ "border-l-2" => "border-left-width: 2px;",
698
+ "border-l-4" => "border-left-width: 4px;",
699
+ "border-l-8" => "border-left-width: 8px;",
700
+
701
+ // Relational marker classes. `group` / `peer` carry no styles of their
702
+ // own — they mark an ancestor / previous-sibling so that `group-*:` /
703
+ // `peer-*:` variant rules on other elements can target their state. We
704
+ // emit an EMPTY-body rule (`.group { }`) rather than returning `None`
705
+ // so the scanner keeps the class in the utility set and the marker
706
+ // survives into the shadow `<style>` (a dropped class would break the
707
+ // relational selectors that reference `.group` / `.peer`).
708
+ "group" => "",
709
+ "peer" => "",
710
+
231
711
  // Width / height keywords
232
712
  "w-full" => "width: 100%;",
233
713
  "w-screen" => "width: 100vw;",
@@ -236,10 +716,238 @@ fn fixed_utility(class_name: &str) -> Option<&'static str> {
236
716
  "h-screen" => "height: 100vh;",
237
717
  "h-auto" => "height: auto;",
238
718
 
719
+ // max-width named scale (Tailwind v4 defaults).
720
+ "max-w-none" => "max-width: none;",
721
+ "max-w-xs" => "max-width: 20rem;",
722
+ "max-w-sm" => "max-width: 24rem;",
723
+ "max-w-md" => "max-width: 28rem;",
724
+ "max-w-lg" => "max-width: 32rem;",
725
+ "max-w-xl" => "max-width: 36rem;",
726
+ "max-w-2xl" => "max-width: 42rem;",
727
+ "max-w-3xl" => "max-width: 48rem;",
728
+ "max-w-4xl" => "max-width: 56rem;",
729
+ "max-w-5xl" => "max-width: 64rem;",
730
+ "max-w-6xl" => "max-width: 72rem;",
731
+ "max-w-7xl" => "max-width: 80rem;",
732
+ "max-w-full" => "max-width: 100%;",
733
+ "max-w-prose" => "max-width: 65ch;",
734
+ "max-w-min" => "max-width: min-content;",
735
+ "max-w-max" => "max-width: max-content;",
736
+ "max-w-fit" => "max-width: fit-content;",
737
+ "max-w-screen-sm" => "max-width: 40rem;",
738
+ "max-w-screen-md" => "max-width: 48rem;",
739
+ "max-w-screen-lg" => "max-width: 64rem;",
740
+ "max-w-screen-xl" => "max-width: 80rem;",
741
+ "max-w-screen-2xl" => "max-width: 96rem;",
742
+
743
+ // Grid template keyword forms (numeric forms handled by
744
+ // `parameterized_utility`).
745
+ "grid-cols-none" => "grid-template-columns: none;",
746
+ "grid-rows-none" => "grid-template-rows: none;",
747
+ "col-span-full" => "grid-column: 1 / -1;",
748
+ "row-span-full" => "grid-row: 1 / -1;",
749
+ "col-auto" => "grid-column: auto;",
750
+ "row-auto" => "grid-row: auto;",
751
+
752
+ // z-index keyword (numeric forms handled by `parameterized_utility`).
753
+ "z-auto" => "z-index: auto;",
754
+
755
+ // Ring (box-shadow) — default width is 3px (Tailwind v4). The numeric
756
+ // `ring-{n}` forms are handled by `parameterized_utility`; `ring-inset`
757
+ // flips the inset slot of the composed shadow. NOTE: the bare `ring`
758
+ // keyword must be matched HERE (fixed) so it never collides with the
759
+ // color path — `ring-<color>` still routes through `brand_color_utility`
760
+ // because its value (`blue-500`, `primary`, …) is not a width.
761
+ "ring" => RING_3,
762
+ "ring-inset" => "--tw-ring-inset: inset;",
763
+
764
+ // --- Motion (Round 2: tailwind-support `motion` track) -------------
765
+ //
766
+ // Transform: this engine emits direct `transform:` declarations per
767
+ // family (no CSS-var composition), so `transform` is the GPU-friendly
768
+ // identity baseline and `transform-none` disables it.
769
+ "transform" => "transform: translate(0, 0) rotate(0) skewX(0) skewY(0) scaleX(1) scaleY(1);",
770
+ "transform-none" => "transform: none;",
771
+
772
+ // Transition shorthands. `transition` is Tailwind's default property
773
+ // set; the property-scoped variants narrow it. All ship the default
774
+ // 150ms / cubic-bezier timing so a bare `transition` animates.
775
+ "transition" => "transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms;",
776
+ "transition-none" => "transition-property: none;",
777
+ "transition-all" => "transition-property: all; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms;",
778
+ "transition-colors" => "transition-property: color, background-color, border-color, text-decoration-color, fill, stroke; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms;",
779
+ "transition-opacity" => "transition-property: opacity; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms;",
780
+ "transition-transform" => "transition-property: transform; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms;",
781
+
782
+ // Timing functions (Tailwind v4 defaults).
783
+ "ease-linear" => "transition-timing-function: linear;",
784
+ "ease-in" => "transition-timing-function: cubic-bezier(0.4, 0, 1, 1);",
785
+ "ease-out" => "transition-timing-function: cubic-bezier(0, 0, 0.2, 1);",
786
+ "ease-in-out" => "transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);",
787
+
788
+ // Animations. The `animation:` shorthand is emitted here; the matching
789
+ // `@keyframes` block is hoisted as a sibling rule by the emitter via
790
+ // `animation_keyframes()` (see emit.rs / lib.rs). `animate-none` clears
791
+ // any running animation and needs no keyframes.
792
+ "animate-none" => "animation: none;",
793
+ "animate-spin" => "animation: spin 1s linear infinite;",
794
+ "animate-ping" => "animation: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite;",
795
+ "animate-pulse" => "animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;",
796
+ "animate-bounce" => "animation: bounce 1s infinite;",
797
+
798
+ // --- Parity round: long-tail fixed utilities ----------------------
799
+
800
+ // Font family (serif completes the sans/mono pair above).
801
+ "font-serif" => "font-family: var(--font-serif);",
802
+
803
+ // Border-radius scale extension (2xl handled above).
804
+ "rounded-3xl" => "border-radius: 1.5rem;",
805
+ "rounded-4xl" => "border-radius: 2rem;",
806
+
807
+ // Box-shadow scale extension (sm/md/lg/none handled above).
808
+ "shadow-xs" => "box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);",
809
+ "shadow-xl" => "box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);",
810
+ "shadow-2xl" => "box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25);",
811
+ "shadow-inner" => "box-shadow: inset 0 2px 4px 0 rgb(0 0 0 / 0.05);",
812
+
813
+ // Stacking / isolation.
814
+ "isolate" => "isolation: isolate;",
815
+ "isolation-auto" => "isolation: auto;",
816
+
817
+ // GPU compositing hint. This engine emits direct `transform`
818
+ // declarations (no var composition), so `transform-gpu` is a benign
819
+ // 3D-context promotion that doesn't clobber a sibling transform family.
820
+ "transform-gpu" => "transform: translateZ(0);",
821
+
822
+ // Text wrapping / balancing (Tailwind v4).
823
+ "text-wrap" => "text-wrap: wrap;",
824
+ "text-nowrap" => "text-wrap: nowrap;",
825
+ "text-balance" => "text-wrap: balance;",
826
+ "text-pretty" => "text-wrap: pretty;",
827
+ "whitespace-nowrap" => "white-space: nowrap;",
828
+ "whitespace-normal" => "white-space: normal;",
829
+ "whitespace-pre" => "white-space: pre;",
830
+ "break-words" => "overflow-wrap: break-word;",
831
+ "break-all" => "word-break: break-all;",
832
+
833
+ // Cursor.
834
+ "cursor-auto" => "cursor: auto;",
835
+ "cursor-default" => "cursor: default;",
836
+ "cursor-pointer" => "cursor: pointer;",
837
+ "cursor-wait" => "cursor: wait;",
838
+ "cursor-text" => "cursor: text;",
839
+ "cursor-move" => "cursor: move;",
840
+ "cursor-help" => "cursor: help;",
841
+ "cursor-not-allowed" => "cursor: not-allowed;",
842
+ "cursor-grab" => "cursor: grab;",
843
+ "cursor-grabbing" => "cursor: grabbing;",
844
+
845
+ // List style.
846
+ "list-none" => "list-style-type: none;",
847
+ "list-disc" => "list-style-type: disc;",
848
+ "list-decimal" => "list-style-type: decimal;",
849
+ "list-inside" => "list-style-position: inside;",
850
+ "list-outside" => "list-style-position: outside;",
851
+
852
+ // Flex item growth/shrink keywords (numeric `grow-0`/`shrink-0` below).
853
+ "grow" => "flex-grow: 1;",
854
+ "grow-0" => "flex-grow: 0;",
855
+ "shrink" => "flex-shrink: 1;",
856
+ "shrink-0" => "flex-shrink: 0;",
857
+
858
+ // Align-self.
859
+ "self-auto" => "align-self: auto;",
860
+ "self-start" => "align-self: flex-start;",
861
+ "self-end" => "align-self: flex-end;",
862
+ "self-center" => "align-self: center;",
863
+ "self-stretch" => "align-self: stretch;",
864
+ "self-baseline" => "align-self: baseline;",
865
+
866
+ // Justify-self / place.
867
+ "justify-items-start" => "justify-items: start;",
868
+ "justify-items-center" => "justify-items: center;",
869
+ "justify-items-end" => "justify-items: end;",
870
+ "place-items-center" => "place-items: center;",
871
+ "place-content-center" => "place-content: center;",
872
+
873
+ // Order keywords (numeric `order-N` below).
874
+ "order-first" => "order: -9999;",
875
+ "order-last" => "order: 9999;",
876
+ "order-none" => "order: 0;",
877
+
878
+ // Object fit/position.
879
+ "object-cover" => "object-fit: cover;",
880
+ "object-contain" => "object-fit: contain;",
881
+ "object-center" => "object-position: center;",
882
+
883
+ // Overflow axes.
884
+ "overflow-x-hidden" => "overflow-x: hidden;",
885
+ "overflow-y-hidden" => "overflow-y: hidden;",
886
+ "overflow-x-auto" => "overflow-x: auto;",
887
+ "overflow-y-auto" => "overflow-y: auto;",
888
+
889
+ // Outline keywords (numeric width/offset handled in
890
+ // `parameterized_utility`). `outline-none` follows Tailwind v4 (a
891
+ // transparent solid outline so high-contrast modes still show focus).
892
+ "outline-none" => "outline: 2px solid transparent; outline-offset: 2px;",
893
+ "outline-hidden" => "outline: 2px solid transparent; outline-offset: 2px;",
894
+ "appearance-none" => "appearance: none;",
895
+
896
+ // Generated-content reset (e.g. `marker:content-none`, `before:content-none`).
897
+ "content-none" => "content: none;",
898
+
899
+ // Width/height intrinsic keywords.
900
+ "w-min" => "width: min-content;",
901
+ "w-max" => "width: max-content;",
902
+ "w-fit" => "width: fit-content;",
903
+ "h-min" => "height: min-content;",
904
+ "h-max" => "height: max-content;",
905
+ "h-fit" => "height: fit-content;",
906
+ "size-full" => "width: 100%; height: 100%;",
907
+ "size-auto" => "width: auto; height: auto;",
908
+ "size-min" => "width: min-content; height: min-content;",
909
+ "size-max" => "width: max-content; height: max-content;",
910
+ "size-fit" => "width: fit-content; height: fit-content;",
911
+
912
+ // Backdrop blur (base keyword; numeric scale below).
913
+ "backdrop-blur" => "backdrop-filter: blur(8px);",
914
+ "blur" => "filter: blur(8px);",
915
+ "blur-none" => "filter: none;",
916
+
917
+ // Screen-reader-only (a11y). Visually hidden but accessible.
918
+ "sr-only" => "position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border-width: 0;",
919
+ "not-sr-only" => "position: static; width: auto; height: auto; padding: 0; margin: 0; overflow: visible; clip: auto; white-space: normal;",
920
+
239
921
  _ => return None,
240
922
  })
241
923
  }
242
924
 
925
+ /// The Tailwind v4 ring recipe, parameterized on the ring width in pixels.
926
+ ///
927
+ /// A ring is a `box-shadow` composed from `--tw-ring-*` custom properties so it
928
+ /// can layer with `--tw-ring-offset-*` (set by `ring-offset-{n}`) and an
929
+ /// inset flag (`ring-inset`), and still coexist with a regular `shadow-*`:
930
+ ///
931
+ /// ```text
932
+ /// --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
933
+ /// --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(<n>px + var(--tw-ring-offset-width)) var(--tw-ring-color);
934
+ /// box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
935
+ /// ```
936
+ ///
937
+ /// The ring spreads by `<n>px + offset-width`; the offset shadow paints the gap
938
+ /// between the element edge and the ring in `--tw-ring-offset-color`.
939
+ fn ring_shadow(width_px: u32) -> String {
940
+ format!(
941
+ "--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); \
942
+ --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc({width_px}px + var(--tw-ring-offset-width)) var(--tw-ring-color); \
943
+ box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);"
944
+ )
945
+ }
946
+
947
+ /// Static body for the default `ring` (3px) so `fixed_utility` can return a
948
+ /// `&'static str`. Kept in sync with [`ring_shadow`]`(3)`.
949
+ const RING_3: &str = "--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color); box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);";
950
+
243
951
  /// Parameterized utilities split on the last `-`: `p-4`, `text-lg`, `gap-2`,
244
952
  /// `text-red-500` (handled via [`palette_color`]), `z-10`, etc.
245
953
  fn parameterized_utility(prefix: &str, value: &str) -> Option<String> {
@@ -250,6 +958,106 @@ fn parameterized_utility(prefix: &str, value: &str) -> Option<String> {
250
958
  }
251
959
  }
252
960
 
961
+ // `space-x-*` / `space-y-*` — emit a nested sibling-margin rule (Tailwind's
962
+ // standard recipe). Modern browsers support native CSS nesting; Vite's
963
+ // Lightning CSS / esbuild minifier handle this in build output.
964
+ if prefix == "space-x" {
965
+ if let Some(rem) = spacing_value(value) {
966
+ return Some(format!("& > * + * {{ margin-inline-start: {rem}; }}"));
967
+ }
968
+ }
969
+ if prefix == "space-y" {
970
+ if let Some(rem) = spacing_value(value) {
971
+ return Some(format!("& > * + * {{ margin-block-start: {rem}; }}"));
972
+ }
973
+ }
974
+
975
+ // `divide-x-*` / `divide-y-*` — sibling borders between adjacent children,
976
+ // reusing the same nested `& > * + *` recipe as `space-*`. Widths are a
977
+ // closed set (0/2/4/8 px); the `-reverse` token flips which sibling owns
978
+ // the border via the Tailwind `--tw-divide-{x,y}-reverse` custom property
979
+ // (kept for API parity even though the simplified `& > * + *` recipe paints
980
+ // both inline/block edges of the trailing sibling).
981
+ if prefix == "divide-x" {
982
+ if value == "reverse" {
983
+ return Some("& > * + * { --tw-divide-x-reverse: 1; }".to_string());
984
+ }
985
+ if let Some(px) = divide_width(value) {
986
+ return Some(format!("& > * + * {{ border-inline-width: {px}; }}"));
987
+ }
988
+ }
989
+ if prefix == "divide-y" {
990
+ if value == "reverse" {
991
+ return Some("& > * + * { --tw-divide-y-reverse: 1; }".to_string());
992
+ }
993
+ if let Some(px) = divide_width(value) {
994
+ return Some(format!("& > * + * {{ border-block-width: {px}; }}"));
995
+ }
996
+ }
997
+
998
+ // Grid templating: `grid-cols-N` / `grid-rows-N` / `col-span-N` / `row-span-N`.
999
+ if prefix == "grid-cols" {
1000
+ if let Some(n) = positive_int(value) {
1001
+ return Some(format!(
1002
+ "grid-template-columns: repeat({n}, minmax(0, 1fr));"
1003
+ ));
1004
+ }
1005
+ }
1006
+ if prefix == "grid-rows" {
1007
+ if let Some(n) = positive_int(value) {
1008
+ return Some(format!("grid-template-rows: repeat({n}, minmax(0, 1fr));"));
1009
+ }
1010
+ }
1011
+ if prefix == "col-span" {
1012
+ if let Some(n) = positive_int(value) {
1013
+ return Some(format!("grid-column: span {n} / span {n};"));
1014
+ }
1015
+ }
1016
+ if prefix == "row-span" {
1017
+ if let Some(n) = positive_int(value) {
1018
+ return Some(format!("grid-row: span {n} / span {n};"));
1019
+ }
1020
+ }
1021
+
1022
+ // Position scale: top/right/bottom/left/inset/inset-x/inset-y on the
1023
+ // spacing scale, plus `auto`. A leading `-` on the prefix (e.g. `-left-2`
1024
+ // arrives here as prefix `-left`) negates the spacing value — Tailwind's
1025
+ // negative-position syntax. `auto` is never negated.
1026
+ {
1027
+ let (neg, base_prefix) = match prefix.strip_prefix('-') {
1028
+ Some(rest) => (true, rest),
1029
+ None => (false, prefix),
1030
+ };
1031
+ if let Some(prop) = position_prop(base_prefix) {
1032
+ if let Some(v) = position_value(value) {
1033
+ if neg && v != "auto" && v != "0" {
1034
+ return Some(format!("{prop}: -{v};"));
1035
+ }
1036
+ return Some(format!("{prop}: {v};"));
1037
+ }
1038
+ }
1039
+ }
1040
+
1041
+ // Named line-height scale: `leading-none|tight|snug|normal|relaxed|loose`
1042
+ // (unitless multipliers) plus the numeric `leading-<n>` step which maps to
1043
+ // the spacing scale (`leading-6` → `1.5rem`), matching Tailwind v4.
1044
+ if prefix == "leading" {
1045
+ if let Some(lh) = leading_value(value) {
1046
+ return Some(format!("line-height: {lh};"));
1047
+ }
1048
+ if let Some(rem) = spacing_value(value) {
1049
+ return Some(format!("line-height: {rem};"));
1050
+ }
1051
+ }
1052
+
1053
+ // Named letter-spacing scale: `tracking-tighter|tight|normal|wide|wider|
1054
+ // widest` in `em` units (Tailwind v4 defaults).
1055
+ if prefix == "tracking" {
1056
+ if let Some(ls) = tracking_value(value) {
1057
+ return Some(format!("letter-spacing: {ls};"));
1058
+ }
1059
+ }
1060
+
253
1061
  // Sizing: w-/h-/min-w-/max-w- with the spacing scale or fractions.
254
1062
  if let Some(prop) = sizing_prop(prefix) {
255
1063
  if let Some(v) = sizing_value(value) {
@@ -257,20 +1065,135 @@ fn parameterized_utility(prefix: &str, value: &str) -> Option<String> {
257
1065
  }
258
1066
  }
259
1067
 
260
- // Font size (with paired line-height, Tailwind defaults).
1068
+ // Font size (with paired line-height, Tailwind defaults). The
1069
+ // `text-<size>/<lh>` slash form overrides the paired line-height with a
1070
+ // step from the spacing scale (`text-sm/6` → line-height 1.5rem).
261
1071
  if prefix == "text" {
262
- if let Some(css) = font_size(value) {
263
- return Some(css.to_string());
1072
+ if let Some((size, lh)) = value.split_once('/') {
1073
+ if let (Some((fs, _)), Some(lhv)) = (font_size_parts(size), spacing_value(lh)) {
1074
+ return Some(format!("font-size: {fs}; line-height: {lhv};"));
1075
+ }
1076
+ }
1077
+ if let Some((fs, lh)) = font_size_parts(value) {
1078
+ return Some(format!("font-size: {fs}; line-height: {lh};"));
264
1079
  }
265
1080
  // text-<color>-<shade> falls through to the color path below.
266
1081
  }
267
1082
 
1083
+ // --- Parity round: parameterized families -------------------------------
1084
+
1085
+ // `size-N` → equal width + height on the spacing scale / fractions.
1086
+ if prefix == "size" {
1087
+ if let Some(v) = sizing_value(value) {
1088
+ return Some(format!("width: {v}; height: {v};"));
1089
+ }
1090
+ }
1091
+
1092
+ // `aspect-<ratio>` — `aspect-square|video|auto` keywords plus bare ratios
1093
+ // (`aspect-16/9`, `aspect-1108/632`). The `/` ratio survives the last-`-`
1094
+ // split as the value, so it lands here intact.
1095
+ if prefix == "aspect" {
1096
+ let ratio = match value {
1097
+ "square" => "1 / 1",
1098
+ "video" => "16 / 9",
1099
+ "auto" => "auto",
1100
+ v if v.split_once('/').is_some_and(|(a, b)| {
1101
+ !a.is_empty()
1102
+ && !b.is_empty()
1103
+ && a.bytes().all(|c| c.is_ascii_digit())
1104
+ && b.bytes().all(|c| c.is_ascii_digit())
1105
+ }) =>
1106
+ {
1107
+ return Some(format!("aspect-ratio: {};", v.replace('/', " / ")));
1108
+ }
1109
+ _ => return None,
1110
+ };
1111
+ return Some(format!("aspect-ratio: {ratio};"));
1112
+ }
1113
+
1114
+ // `order-N` / `-order-N`.
1115
+ {
1116
+ let (neg, base) = match prefix.strip_prefix('-') {
1117
+ Some(rest) => (true, rest),
1118
+ None => (false, prefix),
1119
+ };
1120
+ if base == "order" {
1121
+ if let Some(n) = positive_int_or_zero(value) {
1122
+ return Some(format!("order: {}{n};", if neg { "-" } else { "" }));
1123
+ }
1124
+ }
1125
+ }
1126
+
1127
+ // `blur-N` / `backdrop-blur-N` named filter scale.
1128
+ if prefix == "blur" {
1129
+ if let Some(px) = blur_radius(value) {
1130
+ return Some(format!("filter: blur({px});"));
1131
+ }
1132
+ }
1133
+ if prefix == "backdrop-blur" {
1134
+ if let Some(px) = blur_radius(value) {
1135
+ return Some(format!("backdrop-filter: blur({px});"));
1136
+ }
1137
+ }
1138
+
1139
+ // `outline-N` width (+ `outline-style: solid` so it paints; the engine has
1140
+ // no per-element preflight for outline-style) and `outline-offset-N`
1141
+ // (negative form via the `-` prefix).
1142
+ if prefix == "outline" {
1143
+ if let Some(px) = ring_width(value) {
1144
+ return Some(format!("outline-style: solid; outline-width: {px}px;"));
1145
+ }
1146
+ }
1147
+ {
1148
+ let (neg, base) = match prefix.strip_prefix('-') {
1149
+ Some(rest) => (true, rest),
1150
+ None => (false, prefix),
1151
+ };
1152
+ if base == "outline-offset" {
1153
+ if let Some(px) = ring_width(value) {
1154
+ return Some(format!(
1155
+ "outline-offset: {}{px}px;",
1156
+ if neg { "-" } else { "" }
1157
+ ));
1158
+ }
1159
+ }
1160
+ }
1161
+
1162
+ // Linear-gradient direction: `bg-linear-to-r`, `bg-gradient-to-br`.
1163
+ if prefix == "bg-linear-to" || prefix == "bg-gradient-to" {
1164
+ if let Some(dir) = gradient_dir(value) {
1165
+ return Some(format!(
1166
+ "background-image: linear-gradient({dir}, var(--tw-gradient-stops));"
1167
+ ));
1168
+ }
1169
+ }
1170
+
1171
+ // Negative margins: `-m-4`, `-ml-1`. Only the margin family negates (padding
1172
+ // has no negative form); the positive forms are handled by the spacing path
1173
+ // at the top of this function.
1174
+ if let Some(base) = prefix.strip_prefix('-') {
1175
+ if base.starts_with('m') {
1176
+ if let Some(prop) = spacing_prop(base) {
1177
+ if let Some(v) = spacing_value(value) {
1178
+ if v != "auto" && v != "0" {
1179
+ return Some(format!("{prop}: -{v};"));
1180
+ }
1181
+ return Some(format!("{prop}: {v};"));
1182
+ }
1183
+ }
1184
+ }
1185
+ }
1186
+
268
1187
  // Border radius scale already covered by fixed_utility for named sizes.
269
1188
 
270
- // z-index.
271
- if prefix == "z" {
272
- if value.chars().all(|c| c.is_ascii_digit()) {
273
- return Some(format!("z-index: {value};"));
1189
+ // z-index (`z-10`, `-z-10`).
1190
+ {
1191
+ let (neg, base) = match prefix.strip_prefix('-') {
1192
+ Some(rest) => (true, rest),
1193
+ None => (false, prefix),
1194
+ };
1195
+ if base == "z" && !value.is_empty() && value.chars().all(|c| c.is_ascii_digit()) {
1196
+ return Some(format!("z-index: {}{value};", if neg { "-" } else { "" }));
274
1197
  }
275
1198
  }
276
1199
 
@@ -281,6 +1204,69 @@ fn parameterized_utility(prefix: &str, value: &str) -> Option<String> {
281
1204
  }
282
1205
  }
283
1206
 
1207
+ // Ring width: `ring-{0,1,2,4,8}` → the composed `box-shadow` recipe at the
1208
+ // given pixel width. Routed BEFORE the color path so a numeric value never
1209
+ // falls through to a color lookup; `ring-<color>` (e.g. `ring-blue-500`,
1210
+ // `ring-primary`) has a non-numeric value and is handled by
1211
+ // `brand_color_utility`, which still emits `--tw-ring-color`.
1212
+ if prefix == "ring" {
1213
+ if let Some(n) = ring_width(value) {
1214
+ return Some(ring_shadow(n));
1215
+ }
1216
+ }
1217
+
1218
+ // Ring offset width: `ring-offset-{0,1,2,4,8}` → `--tw-ring-offset-width`.
1219
+ // (Color offsets like `ring-offset-blue-500` are out of scope; the width
1220
+ // value here is always numeric.)
1221
+ if prefix == "ring-offset" {
1222
+ if let Some(n) = ring_width(value) {
1223
+ return Some(format!("--tw-ring-offset-width: {n}px;"));
1224
+ }
1225
+ }
1226
+
1227
+ // --- Motion (Round 2: tailwind-support `motion` track) -----------------
1228
+ //
1229
+ // Translate uses the spacing scale (`translate-x-2` → 0.5rem); negative
1230
+ // forms are routed through `negate_motion` in `utility_to_css`.
1231
+ if prefix == "translate-x" {
1232
+ if let Some(len) = translate_length(value) {
1233
+ return Some(format!("transform: translateX({len});"));
1234
+ }
1235
+ }
1236
+ if prefix == "translate-y" {
1237
+ if let Some(len) = translate_length(value) {
1238
+ return Some(format!("transform: translateY({len});"));
1239
+ }
1240
+ }
1241
+ // Rotate: integer degrees (`rotate-45` → 45deg).
1242
+ if prefix == "rotate" {
1243
+ if let Some(deg) = positive_int(value) {
1244
+ return Some(format!("transform: rotate({deg}deg);"));
1245
+ }
1246
+ }
1247
+ // Scale: percentage value mapped to a unit multiplier (`scale-105` → 1.05).
1248
+ if prefix == "scale" {
1249
+ if let Some(factor) = scale_factor(value) {
1250
+ return Some(format!("transform: scale({factor});"));
1251
+ }
1252
+ }
1253
+ if prefix == "scale-x" {
1254
+ if let Some(factor) = scale_factor(value) {
1255
+ return Some(format!("transform: scaleX({factor});"));
1256
+ }
1257
+ }
1258
+ if prefix == "scale-y" {
1259
+ if let Some(factor) = scale_factor(value) {
1260
+ return Some(format!("transform: scaleY({factor});"));
1261
+ }
1262
+ }
1263
+ // Transition duration: integer milliseconds (`duration-300` → 300ms).
1264
+ if prefix == "duration" {
1265
+ if let Some(ms) = positive_int_or_zero(value) {
1266
+ return Some(format!("transition-duration: {ms}ms;"));
1267
+ }
1268
+ }
1269
+
284
1270
  // Palette colors: bg-red-500, text-slate-700, border-emerald-300.
285
1271
  if let Some(prop) = color_prop(prefix) {
286
1272
  // prefix already stripped to the color path; value is the shade only
@@ -359,9 +1345,9 @@ fn is_palette_token(name: &str) -> bool {
359
1345
  return false;
360
1346
  };
361
1347
  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",
1348
+ "slate", "gray", "zinc", "neutral", "stone", "red", "orange", "amber", "yellow", "lime",
1349
+ "green", "emerald", "teal", "cyan", "sky", "blue", "indigo", "violet", "purple", "fuchsia",
1350
+ "pink", "rose",
365
1351
  ];
366
1352
  FAMILIES.contains(&family)
367
1353
  && matches!(
@@ -404,8 +1390,12 @@ fn spacing_prop(prefix: &str) -> Option<&'static str> {
404
1390
  })
405
1391
  }
406
1392
 
407
- /// Tailwind spacing scale: numeric → `n * 0.25rem`; `px` → `1px`; `0` → `0`.
1393
+ /// Tailwind spacing scale: numeric → `n * 0.25rem`; `px` → `1px`; `0` → `0`;
1394
+ /// `auto` → `auto` (so `mx-auto`, `my-auto`, `mt-auto`, etc. work).
408
1395
  fn spacing_value(value: &str) -> Option<String> {
1396
+ if value == "auto" {
1397
+ return Some("auto".to_string());
1398
+ }
409
1399
  if value == "px" {
410
1400
  return Some("1px".to_string());
411
1401
  }
@@ -418,6 +1408,177 @@ fn spacing_value(value: &str) -> Option<String> {
418
1408
  Some(format!("{}rem", trim_float(rem)))
419
1409
  }
420
1410
 
1411
+ /// Map a `divide-{x,y}-N` width token to its CSS px value. Closed set matching
1412
+ /// Tailwind's divide-width scale (`0/2/4/8`); the bare `divide-x`/`divide-y`
1413
+ /// (1px default) is handled as a fixed utility.
1414
+ fn divide_width(value: &str) -> Option<&'static str> {
1415
+ Some(match value {
1416
+ "0" => "0",
1417
+ "2" => "2px",
1418
+ "4" => "4px",
1419
+ "8" => "8px",
1420
+ _ => return None,
1421
+ })
1422
+ }
1423
+
1424
+ /// Parse a positive integer (used by grid-cols-N, col-span-N, row-span-N).
1425
+ fn positive_int(value: &str) -> Option<u32> {
1426
+ if value.is_empty() || !value.chars().all(|c| c.is_ascii_digit()) {
1427
+ return None;
1428
+ }
1429
+ let n: u32 = value.parse().ok()?;
1430
+ if n == 0 {
1431
+ return None;
1432
+ }
1433
+ Some(n)
1434
+ }
1435
+
1436
+ /// Map a position-scale prefix to its CSS property. `inset` is the all-sides
1437
+ /// shorthand; `inset-x`/`inset-y` are the logical inline/block shorthands.
1438
+ /// (Arbitrary forms `top-[…]` etc. are handled separately by `arbitrary_prop`;
1439
+ /// this is only the named/numeric spacing-scale path.)
1440
+ fn position_prop(prefix: &str) -> Option<&'static str> {
1441
+ Some(match prefix {
1442
+ "top" => "top",
1443
+ "right" => "right",
1444
+ "bottom" => "bottom",
1445
+ "left" => "left",
1446
+ "inset" => "inset",
1447
+ "inset-x" => "inset-inline",
1448
+ "inset-y" => "inset-block",
1449
+ _ => return None,
1450
+ })
1451
+ }
1452
+
1453
+ /// Named line-height scale (`leading-*`), Tailwind v4 defaults. Unitless for
1454
+ /// `none`, otherwise unitless multipliers.
1455
+ fn leading_value(value: &str) -> Option<&'static str> {
1456
+ Some(match value {
1457
+ "none" => "1",
1458
+ "tight" => "1.25",
1459
+ "snug" => "1.375",
1460
+ "normal" => "1.5",
1461
+ "relaxed" => "1.625",
1462
+ "loose" => "2",
1463
+ _ => return None,
1464
+ })
1465
+ }
1466
+
1467
+ /// Named letter-spacing scale (`tracking-*`) in `em` units, Tailwind v4
1468
+ /// defaults.
1469
+ fn tracking_value(value: &str) -> Option<&'static str> {
1470
+ Some(match value {
1471
+ "tighter" => "-0.05em",
1472
+ "tight" => "-0.025em",
1473
+ "normal" => "0em",
1474
+ "wide" => "0.025em",
1475
+ "wider" => "0.05em",
1476
+ "widest" => "0.1em",
1477
+ _ => return None,
1478
+ })
1479
+ }
1480
+
1481
+ /// Tailwind ring-width scale: `0`, `1`, `2`, `4`, `8` (px). Used by both
1482
+ /// `ring-{n}` (ring spread) and `ring-offset-{n}` (offset width). Returns the
1483
+ /// width in pixels, or `None` for any other value so non-width `ring-*` tokens
1484
+ /// (colors, `inset`) fall through to their own handlers.
1485
+ fn ring_width(value: &str) -> Option<u32> {
1486
+ match value {
1487
+ "0" => Some(0),
1488
+ "1" => Some(1),
1489
+ "2" => Some(2),
1490
+ "4" => Some(4),
1491
+ "8" => Some(8),
1492
+ _ => None,
1493
+ }
1494
+ }
1495
+
1496
+ /// Parse a non-negative integer (`duration-0` is valid; `rotate-0` too).
1497
+ fn positive_int_or_zero(value: &str) -> Option<u32> {
1498
+ if value.is_empty() || !value.chars().all(|c| c.is_ascii_digit()) {
1499
+ return None;
1500
+ }
1501
+ value.parse().ok()
1502
+ }
1503
+
1504
+ /// Translate length on the spacing scale, with `px`, fractional steps
1505
+ /// (`translate-x-1/2` → 50%), and rejecting `auto`. Used by `translate-x/y-*`.
1506
+ fn translate_length(value: &str) -> Option<String> {
1507
+ if value == "auto" {
1508
+ return None;
1509
+ }
1510
+ if let Some(pct) = fraction_percent(value) {
1511
+ return Some(pct);
1512
+ }
1513
+ spacing_value(value)
1514
+ }
1515
+
1516
+ /// A `num/den` fraction as a percentage string (`1/2` → `50%`), or `None` if the
1517
+ /// value is not a numeric fraction.
1518
+ fn fraction_percent(value: &str) -> Option<String> {
1519
+ let (num, den) = value.split_once('/')?;
1520
+ let n: f32 = num.parse().ok()?;
1521
+ let d: f32 = den.parse().ok()?;
1522
+ if d == 0.0 {
1523
+ return None;
1524
+ }
1525
+ Some(format!("{}%", trim_float(n / d * 100.0)))
1526
+ }
1527
+
1528
+ /// Position-scale value: fractions resolve to percentages (`left-1/2` → 50%),
1529
+ /// otherwise the spacing scale (incl. `auto`, `px`, `0`).
1530
+ fn position_value(value: &str) -> Option<String> {
1531
+ if let Some(pct) = fraction_percent(value) {
1532
+ return Some(pct);
1533
+ }
1534
+ spacing_value(value)
1535
+ }
1536
+
1537
+ /// Named blur radius scale (`blur-*` / `backdrop-blur-*`), Tailwind v4 defaults.
1538
+ fn blur_radius(value: &str) -> Option<&'static str> {
1539
+ Some(match value {
1540
+ "xs" => "4px",
1541
+ "sm" => "8px",
1542
+ "md" => "12px",
1543
+ "lg" => "16px",
1544
+ "xl" => "24px",
1545
+ "2xl" => "40px",
1546
+ "3xl" => "64px",
1547
+ _ => return None,
1548
+ })
1549
+ }
1550
+
1551
+ /// Scale percentage → unit multiplier. `scale-105` → `1.05`, `scale-0` → `0`,
1552
+ /// `scale-50` → `0.5`.
1553
+ fn scale_factor(value: &str) -> Option<String> {
1554
+ let n = positive_int_or_zero(value)?;
1555
+ let factor = n as f32 / 100.0;
1556
+ Some(trim_float(factor))
1557
+ }
1558
+
1559
+ /// The `@keyframes` block a given `animate-*` utility depends on, or `None` if
1560
+ /// the class is not a keyframe-backed animation (`animate-none`, non-animation
1561
+ /// classes). The emitter hoists this as a sibling rule so the `animation:`
1562
+ /// shorthand has a definition. Re-emitting an identical `@keyframes` is
1563
+ /// idempotent in CSS, so per-occurrence emission is safe.
1564
+ pub fn animation_keyframes(class_name: &str) -> Option<&'static str> {
1565
+ Some(match class_name {
1566
+ "animate-spin" => {
1567
+ "@keyframes spin { to { transform: rotate(360deg); } }"
1568
+ }
1569
+ "animate-ping" => {
1570
+ "@keyframes ping { 75%, 100% { transform: scale(2); opacity: 0; } }"
1571
+ }
1572
+ "animate-pulse" => {
1573
+ "@keyframes pulse { 50% { opacity: 0.5; } }"
1574
+ }
1575
+ "animate-bounce" => {
1576
+ "@keyframes bounce { 0%, 100% { transform: translateY(-25%); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); } 50% { transform: none; animation-timing-function: cubic-bezier(0, 0, 0.2, 1); } }"
1577
+ }
1578
+ _ => return None,
1579
+ })
1580
+ }
1581
+
421
1582
  fn sizing_prop(prefix: &str) -> Option<&'static str> {
422
1583
  Some(match prefix {
423
1584
  "w" => "width",
@@ -443,18 +1604,24 @@ fn sizing_value(value: &str) -> Option<String> {
443
1604
  spacing_value(value)
444
1605
  }
445
1606
 
446
- /// Font-size scale (size + matched line-height), Tailwind defaults.
447
- fn font_size(value: &str) -> Option<&'static str> {
1607
+ /// Font-size scale as `(size, line-height)` parts, Tailwind v4 defaults. The
1608
+ /// caller pairs them into `font-size`/`line-height` declarations, or overrides
1609
+ /// the line-height for the `text-<size>/<lh>` slash form.
1610
+ fn font_size_parts(value: &str) -> Option<(&'static str, &'static str)> {
448
1611
  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;",
1612
+ "xs" => ("0.75rem", "1rem"),
1613
+ "sm" => ("0.875rem", "1.25rem"),
1614
+ "base" => ("1rem", "1.5rem"),
1615
+ "lg" => ("1.125rem", "1.75rem"),
1616
+ "xl" => ("1.25rem", "1.75rem"),
1617
+ "2xl" => ("1.5rem", "2rem"),
1618
+ "3xl" => ("1.875rem", "2.25rem"),
1619
+ "4xl" => ("2.25rem", "2.5rem"),
1620
+ "5xl" => ("3rem", "1"),
1621
+ "6xl" => ("3.75rem", "1"),
1622
+ "7xl" => ("4.5rem", "1"),
1623
+ "8xl" => ("6rem", "1"),
1624
+ "9xl" => ("8rem", "1"),
458
1625
  _ => return None,
459
1626
  })
460
1627
  }