@aihu/css-engine 0.2.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,47 @@ 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
+ for p in POSITION_PREFIXES {
74
+ if let Some(group) = position_prop(p) {
75
+ out.push((p, group));
76
+ }
77
+ }
78
+
79
+ // Named typography scales: `leading-*` (line-height) and `tracking-*`
80
+ // (letter-spacing). Registering the prefix means `leading-tight` and
81
+ // `leading-loose` collide last-wins.
82
+ out.push(("leading", "line-height"));
83
+ out.push(("tracking", "letter-spacing"));
84
+
44
85
  const SIZING_PREFIXES: &[&str] = &["w", "h", "min-w", "max-w", "min-h", "max-h"];
45
86
  for p in SIZING_PREFIXES {
46
87
  if let Some(group) = sizing_prop(p) {
@@ -48,14 +89,22 @@ pub fn conflict_groups() -> Vec<(&'static str, &'static str)> {
48
89
  }
49
90
  }
50
91
 
51
- const COLOR_PREFIXES: &[&str] =
52
- &["bg", "text", "border", "fill", "stroke", "ring", "outline"];
92
+ const COLOR_PREFIXES: &[&str] = &["bg", "text", "border", "fill", "stroke", "ring", "outline"];
53
93
  for p in COLOR_PREFIXES {
54
94
  if let Some(group) = color_prop(p) {
55
95
  out.push((p, group));
56
96
  }
57
97
  }
58
98
 
99
+ // Ring WIDTH (`ring-{n}`) and ring-OFFSET width (`ring-offset-{n}`) both
100
+ // share the `ring` class prefix (the runtime `cn()` `groupKey` splits on the
101
+ // FIRST dash, so `ring-2`, `ring-blue-500`, and `ring-offset-2` all key to
102
+ // prefix `ring`). The `ring` → `--tw-ring-color` entry pushed by the color
103
+ // loop above ALREADY makes every `ring*` utility last-wins as one group, so
104
+ // we deliberately do NOT push a second `("ring", …)` entry here — a duplicate
105
+ // prefix key would collide in the generated `cn()` map. `ring-2` then
106
+ // `ring-4` collapses to `ring-4`, which is the desired last-wins behaviour.
107
+
59
108
  // A handful of non-color/spacing parameterized prefixes with their own group.
60
109
  out.push(("z", "z-index"));
61
110
  out.push(("opacity", "opacity"));
@@ -63,6 +112,22 @@ pub fn conflict_groups() -> Vec<(&'static str, &'static str)> {
63
112
  out.push(("shadow", "box-shadow"));
64
113
  out.push(("font", "font-weight"));
65
114
 
115
+ // Motion families (Round 2: tailwind-support `motion` track). Every motion
116
+ // transform utility emits a single `transform:` declaration, so the engine
117
+ // resolves them via the CSS cascade (last declared wins). For `cn()`
118
+ // last-wins we register one group key per family — translate/rotate/scale
119
+ // dedupe within a family while leaving sibling families independent
120
+ // (matching Tailwind's mental model). Combining families on one element
121
+ // requires an arbitrary value (`transform-[...]`), see the docs note.
122
+ out.push(("translate-x", "translate"));
123
+ out.push(("translate-y", "translate"));
124
+ out.push(("rotate", "rotate"));
125
+ out.push(("scale", "scale"));
126
+ out.push(("scale-x", "scale"));
127
+ out.push(("scale-y", "scale"));
128
+ // Transition / timing / animation each control a single property.
129
+ out.push(("duration", "transition-duration"));
130
+
66
131
  out
67
132
  }
68
133
 
@@ -74,11 +139,31 @@ pub fn utility_to_css(class_name: &str) -> Option<String> {
74
139
  return Some(css);
75
140
  }
76
141
 
142
+ // 1b. Named container context: `@container/<name>` declares a *named* query
143
+ // container so descendant `@<bp>/<name>:` variants can target it. Emits both
144
+ // the container-type and the container-name. (The bare `@container` form is a
145
+ // fixed utility below.)
146
+ if let Some(name) = class_name.strip_prefix("@container/") {
147
+ if !name.is_empty() && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') {
148
+ return Some(format!("container-type: inline-size; container-name: {name};"));
149
+ }
150
+ }
151
+
77
152
  // 2. Fixed long-tail utilities (no value parameter).
78
153
  if let Some(css) = fixed_utility(class_name) {
79
154
  return Some(css.to_string());
80
155
  }
81
156
 
157
+ // 3a. Negative motion utilities: `-translate-x-2`, `-rotate-45`. The leading
158
+ // `-` is not part of any prefix, so we strip it, compile the positive form,
159
+ // and negate the emitted numeric value. Only the negatable motion families
160
+ // (`translate-*`, `rotate-*`) opt in via `negate_motion`.
161
+ if let Some(rest) = class_name.strip_prefix('-') {
162
+ if let Some(css) = negate_motion(rest) {
163
+ return Some(css);
164
+ }
165
+ }
166
+
82
167
  // 3. Parameterized utilities split on the LAST `-` (prefix + value).
83
168
  if let Some(idx) = class_name.rfind('-') {
84
169
  let (prefix, value) = (&class_name[..idx], &class_name[idx + 1..]);
@@ -94,6 +179,30 @@ pub fn utility_to_css(class_name: &str) -> Option<String> {
94
179
  None
95
180
  }
96
181
 
182
+ /// Compile the negative form of a motion transform utility. `rest` is the class
183
+ /// name with its leading `-` already stripped (e.g. `translate-x-2`, `rotate-45`).
184
+ /// Returns the same `transform:` declaration with a negated value. Only
185
+ /// `translate-x/y-*` and `rotate-*` are negatable (Tailwind's `-` prefix set).
186
+ fn negate_motion(rest: &str) -> Option<String> {
187
+ let idx = rest.rfind('-')?;
188
+ let (prefix, value) = (&rest[..idx], &rest[idx + 1..]);
189
+ match prefix {
190
+ "translate-x" => {
191
+ let len = translate_length(value)?;
192
+ Some(format!("transform: translateX(-{len});"))
193
+ }
194
+ "translate-y" => {
195
+ let len = translate_length(value)?;
196
+ Some(format!("transform: translateY(-{len});"))
197
+ }
198
+ "rotate" => {
199
+ let deg = positive_int(value)?;
200
+ Some(format!("transform: rotate(-{deg}deg);"))
201
+ }
202
+ _ => None,
203
+ }
204
+ }
205
+
97
206
  /// Parse `prefix-[value]` arbitrary-value syntax. The bracket content is
98
207
  /// emitted verbatim into the mapped CSS property (edge E7).
99
208
  pub fn parse_arbitrary(class_name: &str) -> Option<String> {
@@ -158,6 +267,12 @@ fn fixed_utility(class_name: &str) -> Option<&'static str> {
158
267
  "hidden" => "display: none;",
159
268
  "contents" => "display: contents;",
160
269
 
270
+ // Container-query context. `@container` (and the named `@container/<name>`
271
+ // form, normalized in `utility_to_css`) marks an element as a query
272
+ // container so descendant `@sm:`/`@md:`/`@lg:` variants resolve against
273
+ // its inline size. Tailwind's default container-type is `inline-size`.
274
+ "@container" => "container-type: inline-size;",
275
+
161
276
  // Flexbox / grid alignment
162
277
  "flex-row" => "flex-direction: row;",
163
278
  "flex-col" => "flex-direction: column;",
@@ -228,6 +343,56 @@ fn fixed_utility(class_name: &str) -> Option<&'static str> {
228
343
  "shadow-lg" => "box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1);",
229
344
  "shadow-none" => "box-shadow: none;",
230
345
 
346
+ // Border widths (fixed N values). Directional + arbitrary `border-N`
347
+ // arms are also wired via `parameterized_utility` below.
348
+ "border-0" => "border-width: 0;",
349
+ "border-2" => "border-width: 2px;",
350
+ "border-4" => "border-width: 4px;",
351
+ "border-8" => "border-width: 8px;",
352
+ "border-x-0" => "border-inline-width: 0;",
353
+ "border-x-2" => "border-inline-width: 2px;",
354
+ "border-x-4" => "border-inline-width: 4px;",
355
+ "border-x-8" => "border-inline-width: 8px;",
356
+ "border-y-0" => "border-block-width: 0;",
357
+ "border-y-2" => "border-block-width: 2px;",
358
+ "border-y-4" => "border-block-width: 4px;",
359
+ "border-y-8" => "border-block-width: 8px;",
360
+
361
+ // `divide-x` / `divide-y` — sibling borders between adjacent children.
362
+ // Bare forms default to 1px (Tailwind parity). These reuse the proven
363
+ // `space-*` nested-rule path: a `& > * + *` block survives the scoped
364
+ // CSS-nesting emission path and minifies to `.divide-x>*+*{...}`.
365
+ // Numeric forms (`divide-x-2`, …) and `-reverse` are handled in
366
+ // `parameterized_utility` (split on the last `-`).
367
+ "divide-x" => "& > * + * { border-inline-width: 1px; }",
368
+ "divide-y" => "& > * + * { border-block-width: 1px; }",
369
+ "border-t-0" => "border-top-width: 0;",
370
+ "border-t-2" => "border-top-width: 2px;",
371
+ "border-t-4" => "border-top-width: 4px;",
372
+ "border-t-8" => "border-top-width: 8px;",
373
+ "border-r-0" => "border-right-width: 0;",
374
+ "border-r-2" => "border-right-width: 2px;",
375
+ "border-r-4" => "border-right-width: 4px;",
376
+ "border-r-8" => "border-right-width: 8px;",
377
+ "border-b-0" => "border-bottom-width: 0;",
378
+ "border-b-2" => "border-bottom-width: 2px;",
379
+ "border-b-4" => "border-bottom-width: 4px;",
380
+ "border-b-8" => "border-bottom-width: 8px;",
381
+ "border-l-0" => "border-left-width: 0;",
382
+ "border-l-2" => "border-left-width: 2px;",
383
+ "border-l-4" => "border-left-width: 4px;",
384
+ "border-l-8" => "border-left-width: 8px;",
385
+
386
+ // Relational marker classes. `group` / `peer` carry no styles of their
387
+ // own — they mark an ancestor / previous-sibling so that `group-*:` /
388
+ // `peer-*:` variant rules on other elements can target their state. We
389
+ // emit an EMPTY-body rule (`.group { }`) rather than returning `None`
390
+ // so the scanner keeps the class in the utility set and the marker
391
+ // survives into the shadow `<style>` (a dropped class would break the
392
+ // relational selectors that reference `.group` / `.peer`).
393
+ "group" => "",
394
+ "peer" => "",
395
+
231
396
  // Width / height keywords
232
397
  "w-full" => "width: 100%;",
233
398
  "w-screen" => "width: 100vw;",
@@ -236,10 +401,115 @@ fn fixed_utility(class_name: &str) -> Option<&'static str> {
236
401
  "h-screen" => "height: 100vh;",
237
402
  "h-auto" => "height: auto;",
238
403
 
404
+ // max-width named scale (Tailwind v4 defaults).
405
+ "max-w-none" => "max-width: none;",
406
+ "max-w-xs" => "max-width: 20rem;",
407
+ "max-w-sm" => "max-width: 24rem;",
408
+ "max-w-md" => "max-width: 28rem;",
409
+ "max-w-lg" => "max-width: 32rem;",
410
+ "max-w-xl" => "max-width: 36rem;",
411
+ "max-w-2xl" => "max-width: 42rem;",
412
+ "max-w-3xl" => "max-width: 48rem;",
413
+ "max-w-4xl" => "max-width: 56rem;",
414
+ "max-w-5xl" => "max-width: 64rem;",
415
+ "max-w-6xl" => "max-width: 72rem;",
416
+ "max-w-7xl" => "max-width: 80rem;",
417
+ "max-w-full" => "max-width: 100%;",
418
+ "max-w-prose" => "max-width: 65ch;",
419
+ "max-w-min" => "max-width: min-content;",
420
+ "max-w-max" => "max-width: max-content;",
421
+ "max-w-fit" => "max-width: fit-content;",
422
+ "max-w-screen-sm" => "max-width: 40rem;",
423
+ "max-w-screen-md" => "max-width: 48rem;",
424
+ "max-w-screen-lg" => "max-width: 64rem;",
425
+ "max-w-screen-xl" => "max-width: 80rem;",
426
+ "max-w-screen-2xl" => "max-width: 96rem;",
427
+
428
+ // Grid template keyword forms (numeric forms handled by
429
+ // `parameterized_utility`).
430
+ "grid-cols-none" => "grid-template-columns: none;",
431
+ "grid-rows-none" => "grid-template-rows: none;",
432
+ "col-span-full" => "grid-column: 1 / -1;",
433
+ "row-span-full" => "grid-row: 1 / -1;",
434
+ "col-auto" => "grid-column: auto;",
435
+ "row-auto" => "grid-row: auto;",
436
+
437
+ // z-index keyword (numeric forms handled by `parameterized_utility`).
438
+ "z-auto" => "z-index: auto;",
439
+
440
+ // Ring (box-shadow) — default width is 3px (Tailwind v4). The numeric
441
+ // `ring-{n}` forms are handled by `parameterized_utility`; `ring-inset`
442
+ // flips the inset slot of the composed shadow. NOTE: the bare `ring`
443
+ // keyword must be matched HERE (fixed) so it never collides with the
444
+ // color path — `ring-<color>` still routes through `brand_color_utility`
445
+ // because its value (`blue-500`, `primary`, …) is not a width.
446
+ "ring" => RING_3,
447
+ "ring-inset" => "--tw-ring-inset: inset;",
448
+
449
+ // --- Motion (Round 2: tailwind-support `motion` track) -------------
450
+ //
451
+ // Transform: this engine emits direct `transform:` declarations per
452
+ // family (no CSS-var composition), so `transform` is the GPU-friendly
453
+ // identity baseline and `transform-none` disables it.
454
+ "transform" => "transform: translate(0, 0) rotate(0) skewX(0) skewY(0) scaleX(1) scaleY(1);",
455
+ "transform-none" => "transform: none;",
456
+
457
+ // Transition shorthands. `transition` is Tailwind's default property
458
+ // set; the property-scoped variants narrow it. All ship the default
459
+ // 150ms / cubic-bezier timing so a bare `transition` animates.
460
+ "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;",
461
+ "transition-none" => "transition-property: none;",
462
+ "transition-all" => "transition-property: all; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms;",
463
+ "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;",
464
+ "transition-opacity" => "transition-property: opacity; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms;",
465
+ "transition-transform" => "transition-property: transform; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms;",
466
+
467
+ // Timing functions (Tailwind v4 defaults).
468
+ "ease-linear" => "transition-timing-function: linear;",
469
+ "ease-in" => "transition-timing-function: cubic-bezier(0.4, 0, 1, 1);",
470
+ "ease-out" => "transition-timing-function: cubic-bezier(0, 0, 0.2, 1);",
471
+ "ease-in-out" => "transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);",
472
+
473
+ // Animations. The `animation:` shorthand is emitted here; the matching
474
+ // `@keyframes` block is hoisted as a sibling rule by the emitter via
475
+ // `animation_keyframes()` (see emit.rs / lib.rs). `animate-none` clears
476
+ // any running animation and needs no keyframes.
477
+ "animate-none" => "animation: none;",
478
+ "animate-spin" => "animation: spin 1s linear infinite;",
479
+ "animate-ping" => "animation: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite;",
480
+ "animate-pulse" => "animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;",
481
+ "animate-bounce" => "animation: bounce 1s infinite;",
482
+
239
483
  _ => return None,
240
484
  })
241
485
  }
242
486
 
487
+ /// The Tailwind v4 ring recipe, parameterized on the ring width in pixels.
488
+ ///
489
+ /// A ring is a `box-shadow` composed from `--tw-ring-*` custom properties so it
490
+ /// can layer with `--tw-ring-offset-*` (set by `ring-offset-{n}`) and an
491
+ /// inset flag (`ring-inset`), and still coexist with a regular `shadow-*`:
492
+ ///
493
+ /// ```text
494
+ /// --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
495
+ /// --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(<n>px + var(--tw-ring-offset-width)) var(--tw-ring-color);
496
+ /// box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
497
+ /// ```
498
+ ///
499
+ /// The ring spreads by `<n>px + offset-width`; the offset shadow paints the gap
500
+ /// between the element edge and the ring in `--tw-ring-offset-color`.
501
+ fn ring_shadow(width_px: u32) -> String {
502
+ format!(
503
+ "--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); \
504
+ --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc({width_px}px + var(--tw-ring-offset-width)) var(--tw-ring-color); \
505
+ box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);"
506
+ )
507
+ }
508
+
509
+ /// Static body for the default `ring` (3px) so `fixed_utility` can return a
510
+ /// `&'static str`. Kept in sync with [`ring_shadow`]`(3)`.
511
+ 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);";
512
+
243
513
  /// Parameterized utilities split on the last `-`: `p-4`, `text-lg`, `gap-2`,
244
514
  /// `text-red-500` (handled via [`palette_color`]), `z-10`, etc.
245
515
  fn parameterized_utility(prefix: &str, value: &str) -> Option<String> {
@@ -250,6 +520,106 @@ fn parameterized_utility(prefix: &str, value: &str) -> Option<String> {
250
520
  }
251
521
  }
252
522
 
523
+ // `space-x-*` / `space-y-*` — emit a nested sibling-margin rule (Tailwind's
524
+ // standard recipe). Modern browsers support native CSS nesting; Vite's
525
+ // Lightning CSS / esbuild minifier handle this in build output.
526
+ if prefix == "space-x" {
527
+ if let Some(rem) = spacing_value(value) {
528
+ return Some(format!("& > * + * {{ margin-inline-start: {rem}; }}"));
529
+ }
530
+ }
531
+ if prefix == "space-y" {
532
+ if let Some(rem) = spacing_value(value) {
533
+ return Some(format!("& > * + * {{ margin-block-start: {rem}; }}"));
534
+ }
535
+ }
536
+
537
+ // `divide-x-*` / `divide-y-*` — sibling borders between adjacent children,
538
+ // reusing the same nested `& > * + *` recipe as `space-*`. Widths are a
539
+ // closed set (0/2/4/8 px); the `-reverse` token flips which sibling owns
540
+ // the border via the Tailwind `--tw-divide-{x,y}-reverse` custom property
541
+ // (kept for API parity even though the simplified `& > * + *` recipe paints
542
+ // both inline/block edges of the trailing sibling).
543
+ if prefix == "divide-x" {
544
+ if value == "reverse" {
545
+ return Some("& > * + * { --tw-divide-x-reverse: 1; }".to_string());
546
+ }
547
+ if let Some(px) = divide_width(value) {
548
+ return Some(format!("& > * + * {{ border-inline-width: {px}; }}"));
549
+ }
550
+ }
551
+ if prefix == "divide-y" {
552
+ if value == "reverse" {
553
+ return Some("& > * + * { --tw-divide-y-reverse: 1; }".to_string());
554
+ }
555
+ if let Some(px) = divide_width(value) {
556
+ return Some(format!("& > * + * {{ border-block-width: {px}; }}"));
557
+ }
558
+ }
559
+
560
+ // Grid templating: `grid-cols-N` / `grid-rows-N` / `col-span-N` / `row-span-N`.
561
+ if prefix == "grid-cols" {
562
+ if let Some(n) = positive_int(value) {
563
+ return Some(format!(
564
+ "grid-template-columns: repeat({n}, minmax(0, 1fr));"
565
+ ));
566
+ }
567
+ }
568
+ if prefix == "grid-rows" {
569
+ if let Some(n) = positive_int(value) {
570
+ return Some(format!("grid-template-rows: repeat({n}, minmax(0, 1fr));"));
571
+ }
572
+ }
573
+ if prefix == "col-span" {
574
+ if let Some(n) = positive_int(value) {
575
+ return Some(format!("grid-column: span {n} / span {n};"));
576
+ }
577
+ }
578
+ if prefix == "row-span" {
579
+ if let Some(n) = positive_int(value) {
580
+ return Some(format!("grid-row: span {n} / span {n};"));
581
+ }
582
+ }
583
+
584
+ // Position scale: top/right/bottom/left/inset/inset-x/inset-y on the
585
+ // spacing scale, plus `auto`. A leading `-` on the prefix (e.g. `-left-2`
586
+ // arrives here as prefix `-left`) negates the spacing value — Tailwind's
587
+ // negative-position syntax. `auto` is never negated.
588
+ {
589
+ let (neg, base_prefix) = match prefix.strip_prefix('-') {
590
+ Some(rest) => (true, rest),
591
+ None => (false, prefix),
592
+ };
593
+ if let Some(prop) = position_prop(base_prefix) {
594
+ if let Some(v) = spacing_value(value) {
595
+ if neg && v != "auto" && v != "0" {
596
+ return Some(format!("{prop}: -{v};"));
597
+ }
598
+ return Some(format!("{prop}: {v};"));
599
+ }
600
+ }
601
+ }
602
+
603
+ // Named line-height scale: `leading-none|tight|snug|normal|relaxed|loose`
604
+ // (unitless multipliers) plus the numeric `leading-<n>` step which maps to
605
+ // the spacing scale (`leading-6` → `1.5rem`), matching Tailwind v4.
606
+ if prefix == "leading" {
607
+ if let Some(lh) = leading_value(value) {
608
+ return Some(format!("line-height: {lh};"));
609
+ }
610
+ if let Some(rem) = spacing_value(value) {
611
+ return Some(format!("line-height: {rem};"));
612
+ }
613
+ }
614
+
615
+ // Named letter-spacing scale: `tracking-tighter|tight|normal|wide|wider|
616
+ // widest` in `em` units (Tailwind v4 defaults).
617
+ if prefix == "tracking" {
618
+ if let Some(ls) = tracking_value(value) {
619
+ return Some(format!("letter-spacing: {ls};"));
620
+ }
621
+ }
622
+
253
623
  // Sizing: w-/h-/min-w-/max-w- with the spacing scale or fractions.
254
624
  if let Some(prop) = sizing_prop(prefix) {
255
625
  if let Some(v) = sizing_value(value) {
@@ -281,6 +651,69 @@ fn parameterized_utility(prefix: &str, value: &str) -> Option<String> {
281
651
  }
282
652
  }
283
653
 
654
+ // Ring width: `ring-{0,1,2,4,8}` → the composed `box-shadow` recipe at the
655
+ // given pixel width. Routed BEFORE the color path so a numeric value never
656
+ // falls through to a color lookup; `ring-<color>` (e.g. `ring-blue-500`,
657
+ // `ring-primary`) has a non-numeric value and is handled by
658
+ // `brand_color_utility`, which still emits `--tw-ring-color`.
659
+ if prefix == "ring" {
660
+ if let Some(n) = ring_width(value) {
661
+ return Some(ring_shadow(n));
662
+ }
663
+ }
664
+
665
+ // Ring offset width: `ring-offset-{0,1,2,4,8}` → `--tw-ring-offset-width`.
666
+ // (Color offsets like `ring-offset-blue-500` are out of scope; the width
667
+ // value here is always numeric.)
668
+ if prefix == "ring-offset" {
669
+ if let Some(n) = ring_width(value) {
670
+ return Some(format!("--tw-ring-offset-width: {n}px;"));
671
+ }
672
+ }
673
+
674
+ // --- Motion (Round 2: tailwind-support `motion` track) -----------------
675
+ //
676
+ // Translate uses the spacing scale (`translate-x-2` → 0.5rem); negative
677
+ // forms are routed through `negate_motion` in `utility_to_css`.
678
+ if prefix == "translate-x" {
679
+ if let Some(len) = translate_length(value) {
680
+ return Some(format!("transform: translateX({len});"));
681
+ }
682
+ }
683
+ if prefix == "translate-y" {
684
+ if let Some(len) = translate_length(value) {
685
+ return Some(format!("transform: translateY({len});"));
686
+ }
687
+ }
688
+ // Rotate: integer degrees (`rotate-45` → 45deg).
689
+ if prefix == "rotate" {
690
+ if let Some(deg) = positive_int(value) {
691
+ return Some(format!("transform: rotate({deg}deg);"));
692
+ }
693
+ }
694
+ // Scale: percentage value mapped to a unit multiplier (`scale-105` → 1.05).
695
+ if prefix == "scale" {
696
+ if let Some(factor) = scale_factor(value) {
697
+ return Some(format!("transform: scale({factor});"));
698
+ }
699
+ }
700
+ if prefix == "scale-x" {
701
+ if let Some(factor) = scale_factor(value) {
702
+ return Some(format!("transform: scaleX({factor});"));
703
+ }
704
+ }
705
+ if prefix == "scale-y" {
706
+ if let Some(factor) = scale_factor(value) {
707
+ return Some(format!("transform: scaleY({factor});"));
708
+ }
709
+ }
710
+ // Transition duration: integer milliseconds (`duration-300` → 300ms).
711
+ if prefix == "duration" {
712
+ if let Some(ms) = positive_int_or_zero(value) {
713
+ return Some(format!("transition-duration: {ms}ms;"));
714
+ }
715
+ }
716
+
284
717
  // Palette colors: bg-red-500, text-slate-700, border-emerald-300.
285
718
  if let Some(prop) = color_prop(prefix) {
286
719
  // prefix already stripped to the color path; value is the shade only
@@ -359,9 +792,9 @@ fn is_palette_token(name: &str) -> bool {
359
792
  return false;
360
793
  };
361
794
  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",
795
+ "slate", "gray", "zinc", "neutral", "stone", "red", "orange", "amber", "yellow", "lime",
796
+ "green", "emerald", "teal", "cyan", "sky", "blue", "indigo", "violet", "purple", "fuchsia",
797
+ "pink", "rose",
365
798
  ];
366
799
  FAMILIES.contains(&family)
367
800
  && matches!(
@@ -404,8 +837,12 @@ fn spacing_prop(prefix: &str) -> Option<&'static str> {
404
837
  })
405
838
  }
406
839
 
407
- /// Tailwind spacing scale: numeric → `n * 0.25rem`; `px` → `1px`; `0` → `0`.
840
+ /// Tailwind spacing scale: numeric → `n * 0.25rem`; `px` → `1px`; `0` → `0`;
841
+ /// `auto` → `auto` (so `mx-auto`, `my-auto`, `mt-auto`, etc. work).
408
842
  fn spacing_value(value: &str) -> Option<String> {
843
+ if value == "auto" {
844
+ return Some("auto".to_string());
845
+ }
409
846
  if value == "px" {
410
847
  return Some("1px".to_string());
411
848
  }
@@ -418,6 +855,140 @@ fn spacing_value(value: &str) -> Option<String> {
418
855
  Some(format!("{}rem", trim_float(rem)))
419
856
  }
420
857
 
858
+ /// Map a `divide-{x,y}-N` width token to its CSS px value. Closed set matching
859
+ /// Tailwind's divide-width scale (`0/2/4/8`); the bare `divide-x`/`divide-y`
860
+ /// (1px default) is handled as a fixed utility.
861
+ fn divide_width(value: &str) -> Option<&'static str> {
862
+ Some(match value {
863
+ "0" => "0",
864
+ "2" => "2px",
865
+ "4" => "4px",
866
+ "8" => "8px",
867
+ _ => return None,
868
+ })
869
+ }
870
+
871
+ /// Parse a positive integer (used by grid-cols-N, col-span-N, row-span-N).
872
+ fn positive_int(value: &str) -> Option<u32> {
873
+ if value.is_empty() || !value.chars().all(|c| c.is_ascii_digit()) {
874
+ return None;
875
+ }
876
+ let n: u32 = value.parse().ok()?;
877
+ if n == 0 {
878
+ return None;
879
+ }
880
+ Some(n)
881
+ }
882
+
883
+ /// Map a position-scale prefix to its CSS property. `inset` is the all-sides
884
+ /// shorthand; `inset-x`/`inset-y` are the logical inline/block shorthands.
885
+ /// (Arbitrary forms `top-[…]` etc. are handled separately by `arbitrary_prop`;
886
+ /// this is only the named/numeric spacing-scale path.)
887
+ fn position_prop(prefix: &str) -> Option<&'static str> {
888
+ Some(match prefix {
889
+ "top" => "top",
890
+ "right" => "right",
891
+ "bottom" => "bottom",
892
+ "left" => "left",
893
+ "inset" => "inset",
894
+ "inset-x" => "inset-inline",
895
+ "inset-y" => "inset-block",
896
+ _ => return None,
897
+ })
898
+ }
899
+
900
+ /// Named line-height scale (`leading-*`), Tailwind v4 defaults. Unitless for
901
+ /// `none`, otherwise unitless multipliers.
902
+ fn leading_value(value: &str) -> Option<&'static str> {
903
+ Some(match value {
904
+ "none" => "1",
905
+ "tight" => "1.25",
906
+ "snug" => "1.375",
907
+ "normal" => "1.5",
908
+ "relaxed" => "1.625",
909
+ "loose" => "2",
910
+ _ => return None,
911
+ })
912
+ }
913
+
914
+ /// Named letter-spacing scale (`tracking-*`) in `em` units, Tailwind v4
915
+ /// defaults.
916
+ fn tracking_value(value: &str) -> Option<&'static str> {
917
+ Some(match value {
918
+ "tighter" => "-0.05em",
919
+ "tight" => "-0.025em",
920
+ "normal" => "0em",
921
+ "wide" => "0.025em",
922
+ "wider" => "0.05em",
923
+ "widest" => "0.1em",
924
+ _ => return None,
925
+ })
926
+ }
927
+
928
+ /// Tailwind ring-width scale: `0`, `1`, `2`, `4`, `8` (px). Used by both
929
+ /// `ring-{n}` (ring spread) and `ring-offset-{n}` (offset width). Returns the
930
+ /// width in pixels, or `None` for any other value so non-width `ring-*` tokens
931
+ /// (colors, `inset`) fall through to their own handlers.
932
+ fn ring_width(value: &str) -> Option<u32> {
933
+ match value {
934
+ "0" => Some(0),
935
+ "1" => Some(1),
936
+ "2" => Some(2),
937
+ "4" => Some(4),
938
+ "8" => Some(8),
939
+ _ => None,
940
+ }
941
+ }
942
+
943
+ /// Parse a non-negative integer (`duration-0` is valid; `rotate-0` too).
944
+ fn positive_int_or_zero(value: &str) -> Option<u32> {
945
+ if value.is_empty() || !value.chars().all(|c| c.is_ascii_digit()) {
946
+ return None;
947
+ }
948
+ value.parse().ok()
949
+ }
950
+
951
+ /// Translate length on the spacing scale, with `px` and fractional steps. Used
952
+ /// by `translate-x/y-*`. Reuses [`spacing_value`] but rejects the `auto`
953
+ /// keyword (translate has no `auto`).
954
+ fn translate_length(value: &str) -> Option<String> {
955
+ if value == "auto" {
956
+ return None;
957
+ }
958
+ spacing_value(value)
959
+ }
960
+
961
+ /// Scale percentage → unit multiplier. `scale-105` → `1.05`, `scale-0` → `0`,
962
+ /// `scale-50` → `0.5`.
963
+ fn scale_factor(value: &str) -> Option<String> {
964
+ let n = positive_int_or_zero(value)?;
965
+ let factor = n as f32 / 100.0;
966
+ Some(trim_float(factor))
967
+ }
968
+
969
+ /// The `@keyframes` block a given `animate-*` utility depends on, or `None` if
970
+ /// the class is not a keyframe-backed animation (`animate-none`, non-animation
971
+ /// classes). The emitter hoists this as a sibling rule so the `animation:`
972
+ /// shorthand has a definition. Re-emitting an identical `@keyframes` is
973
+ /// idempotent in CSS, so per-occurrence emission is safe.
974
+ pub fn animation_keyframes(class_name: &str) -> Option<&'static str> {
975
+ Some(match class_name {
976
+ "animate-spin" => {
977
+ "@keyframes spin { to { transform: rotate(360deg); } }"
978
+ }
979
+ "animate-ping" => {
980
+ "@keyframes ping { 75%, 100% { transform: scale(2); opacity: 0; } }"
981
+ }
982
+ "animate-pulse" => {
983
+ "@keyframes pulse { 50% { opacity: 0.5; } }"
984
+ }
985
+ "animate-bounce" => {
986
+ "@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); } }"
987
+ }
988
+ _ => return None,
989
+ })
990
+ }
991
+
421
992
  fn sizing_prop(prefix: &str) -> Option<&'static str> {
422
993
  Some(match prefix {
423
994
  "w" => "width",