@aihu/css-engine 0.2.5 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -22
- package/crates/aihu-css-core/src/apply.rs +314 -0
- package/crates/aihu-css-core/src/bin/main.rs +8 -7
- package/crates/aihu-css-core/src/cache.rs +8 -5
- package/crates/aihu-css-core/src/emit.rs +195 -36
- package/crates/aihu-css-core/src/lib.rs +15 -2
- package/crates/aihu-css-core/src/palette.rs +301 -0
- package/crates/aihu-css-core/src/style_parser.rs +587 -0
- package/crates/aihu-css-core/src/theme.rs +14 -0
- package/crates/aihu-css-core/src/tokens.rs +1196 -29
- package/crates/aihu-css-core/src/variants.rs +251 -3
- package/crates/aihu-css-core/tests/apply.rs +203 -0
- package/crates/aihu-css-core/tests/apply_regression.rs +150 -0
- package/crates/aihu-css-core/tests/binary_error.rs +61 -0
- package/crates/aihu-css-core/tests/cache.rs +8 -8
- package/crates/aihu-css-core/tests/emit.rs +284 -17
- package/crates/aihu-css-core/tests/parity.rs +274 -0
- package/crates/aihu-css-core/tests/progressive_snapshot.rs +8 -8
- package/crates/aihu-css-core/tests/scoped_snapshot.rs +80 -8
- package/crates/aihu-css-core/tests/snapshots/apply__apply_inside_nested_rule.snap +11 -0
- package/crates/aihu-css-core/tests/snapshots/apply__arbitrary_value_utility_in_apply.snap +8 -0
- package/crates/aihu-css-core/tests/snapshots/apply__arbitrary_value_variant_in_apply.snap +10 -0
- package/crates/aihu-css-core/tests/snapshots/apply__base_utility_inlines_declarations.snap +9 -0
- package/crates/aihu-css-core/tests/snapshots/apply__dark_variant_cascade_in_apply.snap +10 -0
- package/crates/aihu-css-core/tests/snapshots/apply__data_attribute_variant.snap +10 -0
- package/crates/aihu-css-core/tests/snapshots/apply__multi_token_apply.snap +11 -0
- package/crates/aihu-css-core/tests/snapshots/apply__multiple_apply_directives_per_rule.snap +12 -0
- package/crates/aihu-css-core/tests/snapshots/apply__responsive_variant_wraps_media.snap +12 -0
- package/crates/aihu-css-core/tests/snapshots/apply__single_variant_lifts_to_nested_rule.snap +10 -0
- package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__anchor_snapshot.snap +1 -0
- package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__popover_snapshot.snap +1 -0
- package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__text_balance_snapshot.snap +1 -0
- package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__view_transition_snapshot.snap +1 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_animate_spin_hoists_keyframes.snap +25 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_divide_y_nested_rule.snap +24 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_output_for_sfc.snap +1 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_space_y_nested_rule.snap +24 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_transition_and_transform.snap +26 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_with_authored_style_block.snap +6 -2
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_with_global_style_block.snap +5 -2
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__standard_variants.snap +1 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__theme_default_vs_override.snap +2 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__wc_native_variants.snap +1 -0
- package/crates/aihu-css-core/tests/style_parser.rs +257 -0
- package/crates/aihu-css-core/tests/tokens.rs +526 -7
- package/dist/index.d.ts +0 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -18
- package/dist/index.js.map +1 -1
- package/dist/runtime/cn.js +13 -0
- package/dist/runtime/cn.js.map +1 -1
- package/package.json +6 -6
|
@@ -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,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
|
|
106
|
-
|
|
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 =
|
|
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(
|
|
263
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
"
|
|
364
|
-
"
|
|
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
|
|
447
|
-
|
|
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" => "
|
|
450
|
-
"sm" => "
|
|
451
|
-
"base" => "
|
|
452
|
-
"lg" => "
|
|
453
|
-
"xl" => "
|
|
454
|
-
"2xl" => "
|
|
455
|
-
"3xl" => "
|
|
456
|
-
"4xl" => "
|
|
457
|
-
"5xl" => "
|
|
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
|
}
|