@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.
- package/README.md +129 -24
- package/crates/aihu-css-core/src/emit.rs +87 -8
- package/crates/aihu-css-core/src/lib.rs +5 -0
- package/crates/aihu-css-core/src/theme.rs +14 -0
- package/crates/aihu-css-core/src/tokens.rs +579 -8
- package/crates/aihu-css-core/src/variants.rs +101 -0
- package/crates/aihu-css-core/tests/emit.rs +208 -0
- package/crates/aihu-css-core/tests/scoped_snapshot.rs +35 -1
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_animate_spin_hoists_keyframes.snap +25 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_divide_y_nested_rule.snap +23 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_space_y_nested_rule.snap +23 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_transition_and_transform.snap +26 -0
- package/crates/aihu-css-core/tests/tokens.rs +474 -7
- package/dist/index.js +3 -19
- package/dist/index.js.map +1 -1
- package/dist/runtime/cn.js +13 -0
- package/dist/runtime/cn.js.map +1 -1
- package/package.json +2 -2
|
@@ -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
|
-
"
|
|
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
|
-
"
|
|
364
|
-
"
|
|
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",
|