@aihu/css-engine 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +11 -11
  2. package/crates/aihu-css-core/src/apply.rs +314 -0
  3. package/crates/aihu-css-core/src/bin/main.rs +8 -7
  4. package/crates/aihu-css-core/src/cache.rs +8 -5
  5. package/crates/aihu-css-core/src/emit.rs +110 -30
  6. package/crates/aihu-css-core/src/lib.rs +10 -2
  7. package/crates/aihu-css-core/src/palette.rs +301 -0
  8. package/crates/aihu-css-core/src/style_parser.rs +587 -0
  9. package/crates/aihu-css-core/src/tokens.rs +625 -29
  10. package/crates/aihu-css-core/src/variants.rs +154 -7
  11. package/crates/aihu-css-core/tests/apply.rs +203 -0
  12. package/crates/aihu-css-core/tests/apply_regression.rs +150 -0
  13. package/crates/aihu-css-core/tests/binary_error.rs +61 -0
  14. package/crates/aihu-css-core/tests/cache.rs +8 -8
  15. package/crates/aihu-css-core/tests/emit.rs +95 -36
  16. package/crates/aihu-css-core/tests/parity.rs +274 -0
  17. package/crates/aihu-css-core/tests/progressive_snapshot.rs +8 -8
  18. package/crates/aihu-css-core/tests/scoped_snapshot.rs +49 -11
  19. package/crates/aihu-css-core/tests/snapshots/apply__apply_inside_nested_rule.snap +11 -0
  20. package/crates/aihu-css-core/tests/snapshots/apply__arbitrary_value_utility_in_apply.snap +8 -0
  21. package/crates/aihu-css-core/tests/snapshots/apply__arbitrary_value_variant_in_apply.snap +10 -0
  22. package/crates/aihu-css-core/tests/snapshots/apply__base_utility_inlines_declarations.snap +9 -0
  23. package/crates/aihu-css-core/tests/snapshots/apply__dark_variant_cascade_in_apply.snap +10 -0
  24. package/crates/aihu-css-core/tests/snapshots/apply__data_attribute_variant.snap +10 -0
  25. package/crates/aihu-css-core/tests/snapshots/apply__multi_token_apply.snap +11 -0
  26. package/crates/aihu-css-core/tests/snapshots/apply__multiple_apply_directives_per_rule.snap +12 -0
  27. package/crates/aihu-css-core/tests/snapshots/apply__responsive_variant_wraps_media.snap +12 -0
  28. package/crates/aihu-css-core/tests/snapshots/apply__single_variant_lifts_to_nested_rule.snap +10 -0
  29. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__anchor_snapshot.snap +1 -0
  30. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__popover_snapshot.snap +1 -0
  31. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__text_balance_snapshot.snap +1 -0
  32. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__view_transition_snapshot.snap +1 -0
  33. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_animate_spin_hoists_keyframes.snap +1 -1
  34. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_divide_y_nested_rule.snap +1 -0
  35. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_output_for_sfc.snap +1 -0
  36. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_space_y_nested_rule.snap +1 -0
  37. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_transition_and_transform.snap +1 -1
  38. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_with_authored_style_block.snap +6 -2
  39. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_with_global_style_block.snap +5 -2
  40. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__standard_variants.snap +1 -0
  41. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__theme_default_vs_override.snap +2 -0
  42. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__wc_native_variants.snap +1 -0
  43. package/crates/aihu-css-core/tests/style_parser.rs +257 -0
  44. package/crates/aihu-css-core/tests/tokens.rs +52 -0
  45. package/dist/index.d.ts +0 -9
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +26 -18
  48. package/dist/index.js.map +1 -1
  49. 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
- &["top", "right", "bottom", "left", "inset", "inset-x", "inset-y"];
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() && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') {
148
- return Some(format!("container-type: inline-size; container-name: {name};"));
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 value = &class_name[open + 2..class_name.len() - 1];
215
- let prop = arbitrary_prop(prefix)?;
288
+ let raw = &class_name[open + 2..class_name.len() - 1];
289
+ // Optional `<type>:` data-type hint.
290
+ let (hint, body) = match raw.split_once(':') {
291
+ Some((h, v))
292
+ if matches!(
293
+ h,
294
+ "color" | "length" | "angle" | "url" | "number" | "percentage" | "image"
295
+ ) =>
296
+ {
297
+ (Some(h), v)
298
+ }
299
+ _ => (None, raw),
300
+ };
216
301
  // Underscores in arbitrary values stand for spaces (Tailwind convention).
217
- let value = value.replace('_', " ");
302
+ let value = body.replace('_', " ");
303
+
304
+ // Multi-declaration special cases.
305
+ match prefix {
306
+ "size" => return Some(format!("width: {value}; height: {value};")),
307
+ "mask" | "mask-image" => {
308
+ return Some(format!("-webkit-mask-image: {value}; mask-image: {value};"));
309
+ }
310
+ _ => {}
311
+ }
312
+
313
+ let is_color = hint == Some("color") || (hint.is_none() && looks_like_color(&value));
314
+ let prop = match prefix {
315
+ "border" => {
316
+ if is_color {
317
+ "border-color"
318
+ } else {
319
+ "border-width"
320
+ }
321
+ }
322
+ "outline" => {
323
+ if is_color {
324
+ "outline-color"
325
+ } else {
326
+ "outline-width"
327
+ }
328
+ }
329
+ "ring" => {
330
+ if is_color {
331
+ "--tw-ring-color"
332
+ } else {
333
+ "--tw-ring-width"
334
+ }
335
+ }
336
+ _ => arbitrary_prop(prefix)?,
337
+ };
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) = spacing_value(value) {
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(css) = font_size(value) {
633
- return Some(css.to_string());
1072
+ if let Some((size, lh)) = value.split_once('/') {
1073
+ if let (Some((fs, _)), Some(lhv)) = (font_size_parts(size), spacing_value(lh)) {
1074
+ return Some(format!("font-size: {fs}; line-height: {lhv};"));
1075
+ }
1076
+ }
1077
+ if let Some((fs, lh)) = font_size_parts(value) {
1078
+ return Some(format!("font-size: {fs}; line-height: {lh};"));
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
- if prefix == "z" {
642
- if value.chars().all(|c| c.is_ascii_digit()) {
643
- return Some(format!("z-index: {value};"));
1189
+ // z-index (`z-10`, `-z-10`).
1190
+ {
1191
+ let (neg, base) = match prefix.strip_prefix('-') {
1192
+ Some(rest) => (true, rest),
1193
+ None => (false, prefix),
1194
+ };
1195
+ if base == "z" && !value.is_empty() && value.chars().all(|c| c.is_ascii_digit()) {
1196
+ return Some(format!("z-index: {}{value};", if neg { "-" } else { "" }));
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` and fractional steps. Used
952
- /// by `translate-x/y-*`. Reuses [`spacing_value`] but rejects the `auto`
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 + matched line-height), Tailwind defaults.
1018
- fn font_size(value: &str) -> Option<&'static str> {
1607
+ /// Font-size scale as `(size, line-height)` parts, Tailwind v4 defaults. The
1608
+ /// caller pairs them into `font-size`/`line-height` declarations, or overrides
1609
+ /// the line-height for the `text-<size>/<lh>` slash form.
1610
+ fn font_size_parts(value: &str) -> Option<(&'static str, &'static str)> {
1019
1611
  Some(match value {
1020
- "xs" => "font-size: 0.75rem; line-height: 1rem;",
1021
- "sm" => "font-size: 0.875rem; line-height: 1.25rem;",
1022
- "base" => "font-size: 1rem; line-height: 1.5rem;",
1023
- "lg" => "font-size: 1.125rem; line-height: 1.75rem;",
1024
- "xl" => "font-size: 1.25rem; line-height: 1.75rem;",
1025
- "2xl" => "font-size: 1.5rem; line-height: 2rem;",
1026
- "3xl" => "font-size: 1.875rem; line-height: 2.25rem;",
1027
- "4xl" => "font-size: 2.25rem; line-height: 2.5rem;",
1028
- "5xl" => "font-size: 3rem; line-height: 1;",
1612
+ "xs" => ("0.75rem", "1rem"),
1613
+ "sm" => ("0.875rem", "1.25rem"),
1614
+ "base" => ("1rem", "1.5rem"),
1615
+ "lg" => ("1.125rem", "1.75rem"),
1616
+ "xl" => ("1.25rem", "1.75rem"),
1617
+ "2xl" => ("1.5rem", "2rem"),
1618
+ "3xl" => ("1.875rem", "2.25rem"),
1619
+ "4xl" => ("2.25rem", "2.5rem"),
1620
+ "5xl" => ("3rem", "1"),
1621
+ "6xl" => ("3.75rem", "1"),
1622
+ "7xl" => ("4.5rem", "1"),
1623
+ "8xl" => ("6rem", "1"),
1624
+ "9xl" => ("8rem", "1"),
1029
1625
  _ => return None,
1030
1626
  })
1031
1627
  }