@aihu/css-engine 0.3.0 → 0.4.1
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 +11 -11
- 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 +110 -30
- package/crates/aihu-css-core/src/lib.rs +10 -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/tokens.rs +625 -29
- package/crates/aihu-css-core/src/variants.rs +154 -7
- 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 +95 -36
- 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 +49 -11
- 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 +1 -1
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_divide_y_nested_rule.snap +1 -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 +1 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_transition_and_transform.snap +1 -1
- 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 +52 -0
- 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/package.json +6 -6
|
@@ -68,8 +68,9 @@ pub fn conflict_groups() -> Vec<(&'static str, &'static str)> {
|
|
|
68
68
|
// logical inline/block inset shorthands, distinct from the all-sides
|
|
69
69
|
// `inset`. The negative forms (`-top-4`) share the same group as the
|
|
70
70
|
// positive forms because they set the same property.
|
|
71
|
-
const POSITION_PREFIXES: &[&str] =
|
|
72
|
-
|
|
71
|
+
const POSITION_PREFIXES: &[&str] = &[
|
|
72
|
+
"top", "right", "bottom", "left", "inset", "inset-x", "inset-y",
|
|
73
|
+
];
|
|
73
74
|
for p in POSITION_PREFIXES {
|
|
74
75
|
if let Some(group) = position_prop(p) {
|
|
75
76
|
out.push((p, group));
|
|
@@ -134,18 +135,46 @@ pub fn conflict_groups() -> Vec<(&'static str, &'static str)> {
|
|
|
134
135
|
/// Map a single (already variant-stripped) utility class name to its CSS body
|
|
135
136
|
/// (declarations only, no selector). Returns `None` for unknown utilities.
|
|
136
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
|
+
|
|
137
146
|
// 1. Arbitrary-value bracket syntax: bg-[#1a1d24], w-[34ch], text-[14px].
|
|
138
147
|
if let Some(css) = parse_arbitrary(class_name) {
|
|
139
148
|
return Some(css);
|
|
140
149
|
}
|
|
141
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
|
+
|
|
142
165
|
// 1b. Named container context: `@container/<name>` declares a *named* query
|
|
143
166
|
// container so descendant `@<bp>/<name>:` variants can target it. Emits both
|
|
144
167
|
// the container-type and the container-name. (The bare `@container` form is a
|
|
145
168
|
// fixed utility below.)
|
|
146
169
|
if let Some(name) = class_name.strip_prefix("@container/") {
|
|
147
|
-
if !name.is_empty()
|
|
148
|
-
|
|
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
|
+
));
|
|
149
178
|
}
|
|
150
179
|
}
|
|
151
180
|
|
|
@@ -203,21 +232,127 @@ fn negate_motion(rest: &str) -> Option<String> {
|
|
|
203
232
|
}
|
|
204
233
|
}
|
|
205
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
|
+
|
|
206
272
|
/// Parse `prefix-[value]` arbitrary-value syntax. The bracket content is
|
|
207
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`.)
|
|
208
282
|
pub fn parse_arbitrary(class_name: &str) -> Option<String> {
|
|
209
283
|
let open = class_name.find("-[")?;
|
|
210
284
|
if !class_name.ends_with(']') {
|
|
211
285
|
return None;
|
|
212
286
|
}
|
|
213
287
|
let prefix = &class_name[..open];
|
|
214
|
-
let
|
|
215
|
-
|
|
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
|
+
};
|
|
216
301
|
// Underscores in arbitrary values stand for spaces (Tailwind convention).
|
|
217
|
-
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
|
+
};
|
|
218
338
|
Some(format!("{prop}: {value};"))
|
|
219
339
|
}
|
|
220
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
|
+
|
|
221
356
|
/// Map an arbitrary-value prefix to its CSS property.
|
|
222
357
|
fn arbitrary_prop(prefix: &str) -> Option<&'static str> {
|
|
223
358
|
Some(match prefix {
|
|
@@ -225,6 +360,7 @@ fn arbitrary_prop(prefix: &str) -> Option<&'static str> {
|
|
|
225
360
|
"text" => "color",
|
|
226
361
|
"w" => "width",
|
|
227
362
|
"h" => "height",
|
|
363
|
+
"size" => "width",
|
|
228
364
|
"min-w" => "min-width",
|
|
229
365
|
"max-w" => "max-width",
|
|
230
366
|
"min-h" => "min-height",
|
|
@@ -232,23 +368,180 @@ fn arbitrary_prop(prefix: &str) -> Option<&'static str> {
|
|
|
232
368
|
"p" => "padding",
|
|
233
369
|
"px" => "padding-inline",
|
|
234
370
|
"py" => "padding-block",
|
|
371
|
+
"pt" => "padding-top",
|
|
372
|
+
"pr" => "padding-right",
|
|
373
|
+
"pb" => "padding-bottom",
|
|
374
|
+
"pl" => "padding-left",
|
|
235
375
|
"m" => "margin",
|
|
236
376
|
"mx" => "margin-inline",
|
|
237
377
|
"my" => "margin-block",
|
|
378
|
+
"mt" => "margin-top",
|
|
379
|
+
"mr" => "margin-right",
|
|
380
|
+
"mb" => "margin-bottom",
|
|
381
|
+
"ml" => "margin-left",
|
|
238
382
|
"gap" => "gap",
|
|
383
|
+
"gap-x" => "column-gap",
|
|
384
|
+
"gap-y" => "row-gap",
|
|
239
385
|
"rounded" => "border-radius",
|
|
240
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",
|
|
241
395
|
"leading" => "line-height",
|
|
242
396
|
"tracking" => "letter-spacing",
|
|
397
|
+
"aspect" => "aspect-ratio",
|
|
398
|
+
"basis" => "flex-basis",
|
|
243
399
|
"z" => "z-index",
|
|
400
|
+
"order" => "order",
|
|
244
401
|
"top" => "top",
|
|
245
402
|
"right" => "right",
|
|
246
403
|
"bottom" => "bottom",
|
|
247
404
|
"left" => "left",
|
|
248
405
|
"inset" => "inset",
|
|
406
|
+
"translate-x" => "translate",
|
|
249
407
|
"fill" => "fill",
|
|
250
408
|
"stroke" => "stroke",
|
|
251
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",
|
|
252
545
|
_ => return None,
|
|
253
546
|
})
|
|
254
547
|
}
|
|
@@ -293,6 +586,14 @@ fn fixed_utility(class_name: &str) -> Option<&'static str> {
|
|
|
293
586
|
"justify-around" => "justify-content: space-around;",
|
|
294
587
|
"justify-evenly" => "justify-content: space-evenly;",
|
|
295
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
|
+
|
|
296
597
|
// Position
|
|
297
598
|
"static" => "position: static;",
|
|
298
599
|
"relative" => "position: relative;",
|
|
@@ -326,6 +627,11 @@ fn fixed_utility(class_name: &str) -> Option<&'static str> {
|
|
|
326
627
|
"font-semibold" => "font-weight: 600;",
|
|
327
628
|
"font-bold" => "font-weight: 700;",
|
|
328
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);",
|
|
329
635
|
|
|
330
636
|
// Borders / effects
|
|
331
637
|
"border" => "border-width: 1px;",
|
|
@@ -366,6 +672,15 @@ fn fixed_utility(class_name: &str) -> Option<&'static str> {
|
|
|
366
672
|
// `parameterized_utility` (split on the last `-`).
|
|
367
673
|
"divide-x" => "& > * + * { border-inline-width: 1px; }",
|
|
368
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;",
|
|
369
684
|
"border-t-0" => "border-top-width: 0;",
|
|
370
685
|
"border-t-2" => "border-top-width: 2px;",
|
|
371
686
|
"border-t-4" => "border-top-width: 4px;",
|
|
@@ -480,6 +795,129 @@ fn fixed_utility(class_name: &str) -> Option<&'static str> {
|
|
|
480
795
|
"animate-pulse" => "animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;",
|
|
481
796
|
"animate-bounce" => "animation: bounce 1s infinite;",
|
|
482
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
|
+
|
|
483
921
|
_ => return None,
|
|
484
922
|
})
|
|
485
923
|
}
|
|
@@ -591,7 +1029,7 @@ fn parameterized_utility(prefix: &str, value: &str) -> Option<String> {
|
|
|
591
1029
|
None => (false, prefix),
|
|
592
1030
|
};
|
|
593
1031
|
if let Some(prop) = position_prop(base_prefix) {
|
|
594
|
-
if let Some(v) =
|
|
1032
|
+
if let Some(v) = position_value(value) {
|
|
595
1033
|
if neg && v != "auto" && v != "0" {
|
|
596
1034
|
return Some(format!("{prop}: -{v};"));
|
|
597
1035
|
}
|
|
@@ -627,20 +1065,135 @@ fn parameterized_utility(prefix: &str, value: &str) -> Option<String> {
|
|
|
627
1065
|
}
|
|
628
1066
|
}
|
|
629
1067
|
|
|
630
|
-
// 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).
|
|
631
1071
|
if prefix == "text" {
|
|
632
|
-
if let Some(
|
|
633
|
-
|
|
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};"));
|
|
634
1079
|
}
|
|
635
1080
|
// text-<color>-<shade> falls through to the color path below.
|
|
636
1081
|
}
|
|
637
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
|
+
|
|
638
1187
|
// Border radius scale already covered by fixed_utility for named sizes.
|
|
639
1188
|
|
|
640
|
-
// z-index.
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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 { "" }));
|
|
644
1197
|
}
|
|
645
1198
|
}
|
|
646
1199
|
|
|
@@ -948,16 +1501,53 @@ fn positive_int_or_zero(value: &str) -> Option<u32> {
|
|
|
948
1501
|
value.parse().ok()
|
|
949
1502
|
}
|
|
950
1503
|
|
|
951
|
-
/// Translate length on the spacing scale, with `px
|
|
952
|
-
///
|
|
953
|
-
/// keyword (translate has no `auto`).
|
|
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-*`.
|
|
954
1506
|
fn translate_length(value: &str) -> Option<String> {
|
|
955
1507
|
if value == "auto" {
|
|
956
1508
|
return None;
|
|
957
1509
|
}
|
|
1510
|
+
if let Some(pct) = fraction_percent(value) {
|
|
1511
|
+
return Some(pct);
|
|
1512
|
+
}
|
|
958
1513
|
spacing_value(value)
|
|
959
1514
|
}
|
|
960
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
|
+
|
|
961
1551
|
/// Scale percentage → unit multiplier. `scale-105` → `1.05`, `scale-0` → `0`,
|
|
962
1552
|
/// `scale-50` → `0.5`.
|
|
963
1553
|
fn scale_factor(value: &str) -> Option<String> {
|
|
@@ -1014,18 +1604,24 @@ fn sizing_value(value: &str) -> Option<String> {
|
|
|
1014
1604
|
spacing_value(value)
|
|
1015
1605
|
}
|
|
1016
1606
|
|
|
1017
|
-
/// Font-size scale (size
|
|
1018
|
-
|
|
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)> {
|
|
1019
1611
|
Some(match value {
|
|
1020
|
-
"xs" => "
|
|
1021
|
-
"sm" => "
|
|
1022
|
-
"base" => "
|
|
1023
|
-
"lg" => "
|
|
1024
|
-
"xl" => "
|
|
1025
|
-
"2xl" => "
|
|
1026
|
-
"3xl" => "
|
|
1027
|
-
"4xl" => "
|
|
1028
|
-
"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"),
|
|
1029
1625
|
_ => return None,
|
|
1030
1626
|
})
|
|
1031
1627
|
}
|