rnwind 0.0.8 → 0.0.9

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 (131) hide show
  1. package/lib/cjs/core/parser/color.cjs +33 -1
  2. package/lib/cjs/core/parser/color.cjs.map +1 -1
  3. package/lib/cjs/core/parser/color.d.ts +10 -0
  4. package/lib/cjs/core/parser/declaration.cjs +121 -9
  5. package/lib/cjs/core/parser/declaration.cjs.map +1 -1
  6. package/lib/cjs/core/parser/gradient.cjs +46 -12
  7. package/lib/cjs/core/parser/gradient.cjs.map +1 -1
  8. package/lib/cjs/core/parser/gradient.d.ts +2 -1
  9. package/lib/cjs/core/parser/keyframes.cjs +27 -12
  10. package/lib/cjs/core/parser/keyframes.cjs.map +1 -1
  11. package/lib/cjs/core/parser/keyframes.d.ts +11 -0
  12. package/lib/cjs/core/parser/layout-dispatcher.cjs +33 -10
  13. package/lib/cjs/core/parser/layout-dispatcher.cjs.map +1 -1
  14. package/lib/cjs/core/parser/length.cjs +17 -1
  15. package/lib/cjs/core/parser/length.cjs.map +1 -1
  16. package/lib/cjs/core/parser/safe-area.cjs +24 -3
  17. package/lib/cjs/core/parser/safe-area.cjs.map +1 -1
  18. package/lib/cjs/core/parser/theme-vars.cjs +58 -8
  19. package/lib/cjs/core/parser/theme-vars.cjs.map +1 -1
  20. package/lib/cjs/core/parser/tokens.cjs +77 -9
  21. package/lib/cjs/core/parser/tokens.cjs.map +1 -1
  22. package/lib/cjs/core/parser/tokens.d.ts +9 -0
  23. package/lib/cjs/core/parser/transform.cjs +18 -9
  24. package/lib/cjs/core/parser/transform.cjs.map +1 -1
  25. package/lib/cjs/core/parser/tw-parser.cjs +93 -33
  26. package/lib/cjs/core/parser/tw-parser.cjs.map +1 -1
  27. package/lib/cjs/core/parser/typography-dispatcher.cjs +19 -1
  28. package/lib/cjs/core/parser/typography-dispatcher.cjs.map +1 -1
  29. package/lib/cjs/core/parser/typography.cjs +15 -18
  30. package/lib/cjs/core/parser/typography.cjs.map +1 -1
  31. package/lib/cjs/core/parser/typography.d.ts +5 -5
  32. package/lib/cjs/core/style-builder/union-builder.cjs +0 -10
  33. package/lib/cjs/core/style-builder/union-builder.cjs.map +1 -1
  34. package/lib/cjs/core/style-builder/union-builder.d.ts +0 -8
  35. package/lib/cjs/metro/dts.cjs +6 -1
  36. package/lib/cjs/metro/dts.cjs.map +1 -1
  37. package/lib/cjs/metro/transformer.cjs +42 -77
  38. package/lib/cjs/metro/transformer.cjs.map +1 -1
  39. package/lib/cjs/metro/with-config.cjs +9 -29
  40. package/lib/cjs/metro/with-config.cjs.map +1 -1
  41. package/lib/cjs/runtime/hooks/use-scheme.cjs +9 -6
  42. package/lib/cjs/runtime/hooks/use-scheme.cjs.map +1 -1
  43. package/lib/cjs/runtime/hooks/use-scheme.d.ts +7 -4
  44. package/lib/cjs/runtime/index.cjs +1 -1
  45. package/lib/cjs/runtime/index.cjs.map +1 -1
  46. package/lib/cjs/runtime/index.d.ts +1 -1
  47. package/lib/cjs/runtime/lookup-css.cjs +14 -0
  48. package/lib/cjs/runtime/lookup-css.cjs.map +1 -1
  49. package/lib/cjs/runtime/lookup-css.d.ts +11 -0
  50. package/lib/cjs/runtime/resolve.cjs +8 -6
  51. package/lib/cjs/runtime/resolve.cjs.map +1 -1
  52. package/lib/cjs/runtime/wrap.cjs +50 -57
  53. package/lib/cjs/runtime/wrap.cjs.map +1 -1
  54. package/lib/cjs/runtime/wrap.d.ts +10 -4
  55. package/lib/esm/core/parser/color.d.ts +10 -0
  56. package/lib/esm/core/parser/color.mjs +33 -2
  57. package/lib/esm/core/parser/color.mjs.map +1 -1
  58. package/lib/esm/core/parser/declaration.mjs +122 -10
  59. package/lib/esm/core/parser/declaration.mjs.map +1 -1
  60. package/lib/esm/core/parser/gradient.d.ts +2 -1
  61. package/lib/esm/core/parser/gradient.mjs +45 -11
  62. package/lib/esm/core/parser/gradient.mjs.map +1 -1
  63. package/lib/esm/core/parser/keyframes.d.ts +11 -0
  64. package/lib/esm/core/parser/keyframes.mjs +27 -12
  65. package/lib/esm/core/parser/keyframes.mjs.map +1 -1
  66. package/lib/esm/core/parser/layout-dispatcher.mjs +33 -10
  67. package/lib/esm/core/parser/layout-dispatcher.mjs.map +1 -1
  68. package/lib/esm/core/parser/length.mjs +17 -1
  69. package/lib/esm/core/parser/length.mjs.map +1 -1
  70. package/lib/esm/core/parser/safe-area.mjs +24 -3
  71. package/lib/esm/core/parser/safe-area.mjs.map +1 -1
  72. package/lib/esm/core/parser/theme-vars.mjs +58 -8
  73. package/lib/esm/core/parser/theme-vars.mjs.map +1 -1
  74. package/lib/esm/core/parser/tokens.d.ts +9 -0
  75. package/lib/esm/core/parser/tokens.mjs +77 -10
  76. package/lib/esm/core/parser/tokens.mjs.map +1 -1
  77. package/lib/esm/core/parser/transform.mjs +18 -9
  78. package/lib/esm/core/parser/transform.mjs.map +1 -1
  79. package/lib/esm/core/parser/tw-parser.mjs +95 -35
  80. package/lib/esm/core/parser/tw-parser.mjs.map +1 -1
  81. package/lib/esm/core/parser/typography-dispatcher.mjs +19 -1
  82. package/lib/esm/core/parser/typography-dispatcher.mjs.map +1 -1
  83. package/lib/esm/core/parser/typography.d.ts +5 -5
  84. package/lib/esm/core/parser/typography.mjs +15 -18
  85. package/lib/esm/core/parser/typography.mjs.map +1 -1
  86. package/lib/esm/core/style-builder/union-builder.d.ts +0 -8
  87. package/lib/esm/core/style-builder/union-builder.mjs +0 -10
  88. package/lib/esm/core/style-builder/union-builder.mjs.map +1 -1
  89. package/lib/esm/metro/dts.mjs +6 -1
  90. package/lib/esm/metro/dts.mjs.map +1 -1
  91. package/lib/esm/metro/transformer.mjs +42 -77
  92. package/lib/esm/metro/transformer.mjs.map +1 -1
  93. package/lib/esm/metro/with-config.mjs +10 -30
  94. package/lib/esm/metro/with-config.mjs.map +1 -1
  95. package/lib/esm/runtime/hooks/use-scheme.d.ts +7 -4
  96. package/lib/esm/runtime/hooks/use-scheme.mjs +9 -6
  97. package/lib/esm/runtime/hooks/use-scheme.mjs.map +1 -1
  98. package/lib/esm/runtime/index.d.ts +1 -1
  99. package/lib/esm/runtime/index.mjs +1 -1
  100. package/lib/esm/runtime/index.mjs.map +1 -1
  101. package/lib/esm/runtime/lookup-css.d.ts +11 -0
  102. package/lib/esm/runtime/lookup-css.mjs +14 -1
  103. package/lib/esm/runtime/lookup-css.mjs.map +1 -1
  104. package/lib/esm/runtime/resolve.mjs +9 -7
  105. package/lib/esm/runtime/resolve.mjs.map +1 -1
  106. package/lib/esm/runtime/wrap.d.ts +10 -4
  107. package/lib/esm/runtime/wrap.mjs +50 -57
  108. package/lib/esm/runtime/wrap.mjs.map +1 -1
  109. package/package.json +1 -1
  110. package/src/core/parser/color.ts +32 -1
  111. package/src/core/parser/declaration.ts +119 -10
  112. package/src/core/parser/gradient.ts +48 -11
  113. package/src/core/parser/keyframes.ts +31 -3
  114. package/src/core/parser/layout-dispatcher.ts +32 -9
  115. package/src/core/parser/length.ts +18 -1
  116. package/src/core/parser/safe-area.ts +23 -2
  117. package/src/core/parser/theme-vars.ts +75 -8
  118. package/src/core/parser/tokens.ts +76 -9
  119. package/src/core/parser/transform.ts +19 -8
  120. package/src/core/parser/tw-parser.ts +95 -30
  121. package/src/core/parser/typography-dispatcher.ts +20 -1
  122. package/src/core/parser/typography.ts +15 -15
  123. package/src/core/style-builder/union-builder.ts +0 -11
  124. package/src/metro/dts.ts +6 -1
  125. package/src/metro/transformer.ts +42 -78
  126. package/src/metro/with-config.ts +10 -29
  127. package/src/runtime/hooks/use-scheme.ts +9 -6
  128. package/src/runtime/index.ts +1 -1
  129. package/src/runtime/lookup-css.ts +14 -0
  130. package/src/runtime/resolve.ts +9 -7
  131. package/src/runtime/wrap.tsx +57 -61
@@ -32,7 +32,9 @@ function byteToHex(byte: number): string {
32
32
  */
33
33
  function rgbIntsToString(r: number, g: number, b: number, alpha: number): string {
34
34
  if (alpha >= 1) return `#${byteToHex(r)}${byteToHex(g)}${byteToHex(b)}`
35
- return `rgba(${r}, ${g}, ${b}, ${alpha})`
35
+ // Round the alpha to shed f32 noise (`0.2 0.20000000298…`) — RN parses
36
+ // either, but the rounded form keeps generated StyleSheets compact.
37
+ return `rgba(${r}, ${g}, ${b}, ${Math.round(alpha * 10_000) / 10_000})`
36
38
  }
37
39
 
38
40
  /**
@@ -134,6 +136,35 @@ function xyzToHex(color: { type: 'xyz-d50' | 'xyz-d65'; x: number; y: number; z:
134
136
  return withAlpha(formatHex({ mode, x: color.x, y: color.y, z: color.z }) ?? null, color.alpha)
135
137
  }
136
138
 
139
+ /**
140
+ * Modern CSS color functions RN's native view manager can't paint —
141
+ * everything else (hex, `rgb()`/`rgba()`, `hsl()`/`hsla()`, named colors,
142
+ * `transparent`, `currentColor`) RN reads directly and must pass through
143
+ * untouched. Custom `@theme` tokens reach the parser as `var(--color-x)`
144
+ * (only the default palette is `theme(inline)`-d), so they flow through the
145
+ * unparsed-string path where the typed {@link cssColorToString} never runs —
146
+ * this is the one place that lowers their wide-gamut values to sRGB.
147
+ */
148
+ const RN_UNREADABLE_COLOR_PREFIXES: readonly string[] = ['oklch(', 'oklab(', 'lab(', 'lch(', 'color(', 'hwb(']
149
+
150
+ /**
151
+ * Lower a wide-gamut / modern CSS color STRING (`oklch(…)`, `lab(…)`,
152
+ * `color(display-p3 …)`, …) to an sRGB hex/rgba string RN can paint. Returns
153
+ * `null` for anything RN already understands (hex, rgb, hsl, named) so the
154
+ * caller keeps the original text — only the unrepresentable forms convert.
155
+ * Mirrors {@link cssColorToString}'s culori lowering for the string path.
156
+ * @param text Resolved CSS color text (post theme-var substitution).
157
+ * @returns sRGB color string, or `null` when no conversion is needed/possible.
158
+ */
159
+ export function normalizeColorString(text: string): string | null {
160
+ const lower = text.trim().toLowerCase()
161
+ if (!RN_UNREADABLE_COLOR_PREFIXES.some((prefix) => lower.startsWith(prefix))) return null
162
+ const parsed = culoriRgb(text)
163
+ if (!parsed || ![parsed.r, parsed.g, parsed.b].every((v) => typeof v === 'number' && Number.isFinite(v))) return null
164
+ const alpha = typeof parsed.alpha === 'number' ? parsed.alpha : 1
165
+ return rgbIntsToString(clampByte(parsed.r * 255), clampByte(parsed.g * 255), clampByte(parsed.b * 255), alpha)
166
+ }
167
+
137
168
  /**
138
169
  * Convert a lightningcss `CssColor` to an RN-safe color string. RGB
139
170
  * passes through unchanged. LAB / LCH / OKLAB / OKLCH / `color(xyz-…)`
@@ -1,7 +1,7 @@
1
1
  /* eslint-disable sonarjs/cognitive-complexity -- the main Declaration → RN-entries dispatcher is intentionally a flat switch so each branch keeps its narrowed value type */
2
2
  import type { Declaration as LcDeclaration, TokenOrValue } from 'lightningcss'
3
3
  import { kebabToCamel } from './case-convert'
4
- import { cssColorToString } from './color'
4
+ import { cssColorToString, normalizeColorString } from './color'
5
5
  import { dimensionPercentageToNumber, gapValueToValue, lengthPercentageOrAutoToValue, sizeLikeToValue } from './length'
6
6
  import {
7
7
  expandBorderColor,
@@ -38,6 +38,50 @@ const UNSUPPORTED_LOGICAL_PROPS = new Set([
38
38
  'border-block-end-style',
39
39
  ])
40
40
 
41
+ /**
42
+ * Web-only CSS properties Tailwind v4 emits that have NO React Native style
43
+ * equivalent. Without this denylist they reach the generic `kebabToCamel`
44
+ * fallback and emit dead keys (`objectPosition`, `textWrap`, `willChange`,
45
+ * `float`, `columns`, `-webkit-line-clamp` → `WebkitLineClamp`, …) that bloat
46
+ * every StyleSheet and read as "supported" when they do nothing. Dropping the
47
+ * property name (kebab-case, pre-camel) is safe: it only excludes known
48
+ * web-only props — anything RN supports is handled by a typed branch above.
49
+ * (line-clamp's real RN behaviour comes from `numberOfLines` in text-truncate.)
50
+ */
51
+ const RN_UNSUPPORTED_PROPERTIES: ReadonlySet<string> = new Set([
52
+ 'object-position',
53
+ 'text-wrap',
54
+ 'will-change',
55
+ 'columns',
56
+ 'float',
57
+ 'clear',
58
+ 'table-layout',
59
+ 'caption-side',
60
+ 'transform-style',
61
+ 'background-blend-mode',
62
+ 'scroll-behavior',
63
+ 'overscroll-behavior',
64
+ 'overscroll-behavior-x',
65
+ 'overscroll-behavior-y',
66
+ 'scroll-snap-type',
67
+ 'scroll-snap-align',
68
+ 'scroll-snap-stop',
69
+ 'break-after',
70
+ 'break-before',
71
+ 'break-inside',
72
+ 'content',
73
+ 'field-sizing',
74
+ 'forced-color-adjust',
75
+ 'text-shadow',
76
+ 'touch-action',
77
+ 'backdrop-filter',
78
+ '-webkit-backdrop-filter',
79
+ '-webkit-line-clamp',
80
+ '-webkit-box-orient',
81
+ '-webkit-font-smoothing',
82
+ '-moz-osx-font-smoothing',
83
+ ])
84
+
41
85
  /** CSS single-sided logical-inline property → RN writing-direction Yoga key. */
42
86
  const LOGICAL_INLINE_TO_RN: Record<string, string> = {
43
87
  'margin-inline-start': 'marginStart',
@@ -46,6 +90,22 @@ const LOGICAL_INLINE_TO_RN: Record<string, string> = {
46
90
  'padding-inline-end': 'paddingEnd',
47
91
  }
48
92
 
93
+ /**
94
+ * Logical border-COLOR property → physical RN side key(s). Custom `@theme`
95
+ * tokens reach the unparsed path as `border-inline-color: var(--color-x)`,
96
+ * which a plain `kebabToCamel` would turn into `borderInlineColor` — a key RN
97
+ * silently drops, so the border color never paints. Lower to the physical
98
+ * keys RN actually honors, matching the typed `dispatchBorderDeclaration`.
99
+ */
100
+ const LOGICAL_BORDER_COLOR_SIDES: Record<string, readonly string[]> = {
101
+ 'border-inline-color': ['borderLeftColor', 'borderRightColor'],
102
+ 'border-block-color': ['borderTopColor', 'borderBottomColor'],
103
+ 'border-inline-start-color': ['borderLeftColor'],
104
+ 'border-inline-end-color': ['borderRightColor'],
105
+ 'border-block-start-color': ['borderTopColor'],
106
+ 'border-block-end-color': ['borderBottomColor'],
107
+ }
108
+
49
109
  /**
50
110
  * Pick the closest predefined CSS easing keyword for a `cubic-bezier`
51
111
  * control-point set. Mirrors {@link snapCubicBezierToKeyword} in
@@ -88,6 +148,23 @@ function coerceCubicBezierString(value: string): string {
88
148
  return snapBezier(Number(x1), Number(y1), Number(x2), Number(y2))
89
149
  }
90
150
 
151
+ /**
152
+ * Whether `text` has a whitespace char OUTSIDE any parenthesised group —
153
+ * the signature of a multi-token CSS value (`2px solid #000`) rather than a
154
+ * single color (`#000`, `rgb(1 2 3)`, `red`).
155
+ * @param text Resolved value text.
156
+ * @returns True when a top-level space is present.
157
+ */
158
+ function hasTopLevelSpace(text: string): boolean {
159
+ let depth = 0
160
+ for (const ch of text.trim()) {
161
+ if (ch === '(') depth += 1
162
+ else if (ch === ')') depth = Math.max(0, depth - 1)
163
+ else if (depth === 0 && (ch === ' ' || ch === '\t' || ch === '\n')) return true
164
+ }
165
+ return false
166
+ }
167
+
91
168
  /**
92
169
  * Fast-path check for the handful of color property names Tailwind emits.
93
170
  * @param property Kebab-case CSS property name.
@@ -97,6 +174,11 @@ function isColorProperty(property: string): boolean {
97
174
  return (
98
175
  property === 'color' ||
99
176
  property === 'background-color' ||
177
+ // SVG paint props (`fill-<token>` / `stroke-<token>` via react-native-svg) —
178
+ // they don't end in `-color`, so without this they'd skip normalization and
179
+ // leak a raw `oklch(…)` string for custom `@theme` tokens.
180
+ property === 'fill' ||
181
+ property === 'stroke' ||
100
182
  (property.startsWith('border-') && property.endsWith('-color')) ||
101
183
  property.endsWith('-color')
102
184
  )
@@ -118,6 +200,7 @@ function unparsedToEntries(
118
200
  themeVars: ReadonlyMap<string, string> | undefined,
119
201
  ): readonly RNEntry[] {
120
202
  if (property.length === 0) return []
203
+ if (RN_UNSUPPORTED_PROPERTIES.has(property)) return []
121
204
  // Safe-area detection runs BEFORE token serialization because
122
205
  // `env()` serializes to an empty string, which would strip the side
123
206
  // info we need. If the tokens encode a recognised `env(safe-area-inset-*)`
@@ -130,12 +213,15 @@ function unparsedToEntries(
130
213
  if (themeVars && themeVars.size > 0) text = substituteThemeVars(text, themeVars)
131
214
  const coerced = coerceUnparsedValue(text)
132
215
  if (coerced === null) return []
133
- // Skip values that didn't resolve past their `var()` wrapper they
134
- // came from a `@property --tw-*` token without a real fallback.
135
- // Tailwind v4's `border-N` emits `border-style: var(--tw-border-style)`
136
- // expecting the cascade to fill it in; in RN we drop them and rely on
137
- // RN's default (solid).
138
- if (typeof coerced === 'string' && coerced.startsWith('var(')) return []
216
+ // Skip values still carrying an unresolved `var(--tw-*)` ANYWHERE in the
217
+ // string — they came from a `@property --tw-*` composable with no real
218
+ // fallback (e.g. `filter: blur(8px) var(--tw-brightness) …`,
219
+ // `transform: rotateX(45deg) var(--tw-rotate-y) …`, `touch-action`,
220
+ // `scroll-snap-type`). RN can't evaluate the cascade, so a leaked `var()`
221
+ // makes the whole declaration an invalid string RN rejects drop it and
222
+ // rely on RN's default rather than emit garbage. `var(--color-*)` refs are
223
+ // already substituted above, so anything left is a genuine composable miss.
224
+ if (typeof coerced === 'string' && coerced.includes('var(')) return []
139
225
  // RN `fontFamily` is a single typeface, not a CSS fallback list — take
140
226
  // the first family so `--font-x: "Name", sans-serif` works out of the box.
141
227
  if (property === 'font-family' && typeof coerced === 'string') {
@@ -152,9 +238,21 @@ function unparsedToEntries(
152
238
  return [[kebabToCamel(property), coerceCubicBezierString(coerced)]]
153
239
  }
154
240
  if (isColorProperty(property) && typeof coerced === 'string') {
155
- // Resolved user-theme color strings (e.g. `#ff0099`) go straight to
156
- // the RN style no further conversion needed.
157
- return [[kebabToCamel(property), coerced]]
241
+ // A color is a single token. Tailwind compiles an arbitrary shorthand like
242
+ // `border-[2px_solid_#000]` to `border-color: 2px solid #000` (invalid for
243
+ // a color property → unparsed), which would otherwise emit
244
+ // `borderColor: "2px solid #000000"` — a string RN rejects. A top-level
245
+ // space (outside parens — `rgb(1 2 3)` keeps its inner spaces) means it's a
246
+ // multi-token shorthand, not a color: drop it.
247
+ if (hasTopLevelSpace(coerced)) return []
248
+ // Lower modern color spaces (`oklch(…)`, `lab(…)`, `color(p3 …)`) that
249
+ // RN can't paint to sRGB; hex/rgb/hsl/named pass through unchanged.
250
+ const color = normalizeColorString(coerced) ?? coerced
251
+ // Logical border-color utilities must lower to physical RN side keys —
252
+ // RN ignores `borderInlineColor` / `borderInlineStartColor`.
253
+ const sides = LOGICAL_BORDER_COLOR_SIDES[property]
254
+ if (sides) return sides.map((key): RNEntry => [key, color])
255
+ return [[kebabToCamel(property), color]]
158
256
  }
159
257
  return [[kebabToCamel(property), coerced]]
160
258
  }
@@ -379,6 +477,17 @@ function dispatchLogicalInline(decl: LcDeclaration): readonly RNEntry[] | null {
379
477
  const v = lengthPercentageOrAutoToValue(decl.value)
380
478
  return v === null ? [] : [['end', v]]
381
479
  }
480
+ // Logical border-radius corners (`rounded-s/e/ss/se/ee/es-*`). RN has
481
+ // matching keys — `kebabToCamel('border-start-start-radius')` is exactly
482
+ // `borderStartStartRadius`. Value is a `[x, y]` tuple like physical corners.
483
+ case 'border-start-start-radius':
484
+ case 'border-start-end-radius':
485
+ case 'border-end-start-radius':
486
+ case 'border-end-end-radius': {
487
+ const [xAxis] = decl.value
488
+ const v = dimensionPercentageToNumber(xAxis)
489
+ return v === null ? [] : [[kebabToCamel(decl.property), v]]
490
+ }
382
491
  default: {
383
492
  return null
384
493
  }
@@ -22,8 +22,10 @@
22
22
  * {@link GradientAtomInfo} record the transformer can read per atom.
23
23
  */
24
24
 
25
+ import { formatHex as culoriFormatHex } from 'culori'
25
26
  import type { Declaration as LcDeclaration, TokenOrValue } from 'lightningcss'
26
- import { cssColorToString } from './color'
27
+ import { cssColorToString, normalizeColorString } from './color'
28
+ import { serializeTokens, substituteThemeVars } from './tokens'
27
29
 
28
30
  /**
29
31
  * The four roles an atom can play in a Tailwind v4 gradient. `from`,
@@ -59,32 +61,62 @@ export type GradientDirection =
59
61
  * return the atom's role + data. Returns `null` for rules that don't
60
62
  * belong to a gradient utility.
61
63
  * @param declarations Declarations from one lightningcss style rule.
64
+ * @param themeVars
62
65
  * @returns Gradient info, or null.
63
66
  */
64
- function detectGradientAtom(declarations: readonly LcDeclaration[]): GradientAtomInfo | null {
67
+ function detectGradientAtom(
68
+ declarations: readonly LcDeclaration[],
69
+ themeVars?: ReadonlyMap<string, string>,
70
+ ): GradientAtomInfo | null {
65
71
  for (const decl of declarations) {
66
72
  if (decl.property !== 'custom') continue
67
73
  const custom = decl.value as { name: { name: string } | string; value?: readonly TokenOrValue[] }
68
74
  const name = typeof custom.name === 'string' ? custom.name : custom.name.name
69
75
  if (!name.startsWith('--tw-gradient-')) continue
70
- if (name === '--tw-gradient-from') return fromColor('from', custom.value)
71
- if (name === '--tw-gradient-via') return fromColor('via', custom.value)
72
- if (name === '--tw-gradient-to') return fromColor('to', custom.value)
76
+ if (name === '--tw-gradient-from') return fromColor('from', custom.value, themeVars)
77
+ if (name === '--tw-gradient-via') return fromColor('via', custom.value, themeVars)
78
+ if (name === '--tw-gradient-to') return fromColor('to', custom.value, themeVars)
73
79
  if (name === '--tw-gradient-position') return fromDirection(custom.value)
74
80
  }
75
81
  return null
76
82
  }
77
83
 
78
84
  /**
79
- * Extract a single color token from a custom-property value list and
80
- * return the `{role, color}` record. Returns `null` when no color token
81
- * is present (defensive Tailwind always emits one, but future output
82
- * shapes may not).
85
+ * Lower a resolved gradient-stop color string to one RN's `LinearGradient`
86
+ * `colors=` array accepts. Hex/rgb/hsl pass through; modern spaces
87
+ * (`oklch(…)`, `lab(…)`, `color(p3 …)`) and named colors go through culori.
88
+ * @param text Resolved color text (post theme-var substitution).
89
+ * @returns sRGB color string, or null when unparseable.
90
+ */
91
+ function resolveGradientColor(text: string): string | null {
92
+ if (text.startsWith('#') || text.startsWith('rgb') || text.startsWith('hsl')) return text
93
+ const normalized = normalizeColorString(text)
94
+ if (normalized) return normalized
95
+ try {
96
+ const hex = culoriFormatHex(text)
97
+ return typeof hex === 'string' ? hex : null
98
+ } catch {
99
+ return null
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Extract a stop color from a `--tw-gradient-*` value list. The default
105
+ * Tailwind palette is inlined to a typed `color` token; CUSTOM `@theme`
106
+ * tokens arrive as an unresolved `var(--color-x)` token list, so when no
107
+ * typed color is present we serialize, substitute `themeVars`, and lower
108
+ * the result to sRGB — without this, `from-<token>` / `via-<token>` /
109
+ * `to-<token>` dropped the stop entirely.
83
110
  * @param role Target role (`from` / `via` / `to`).
84
111
  * @param tokens Value tokens from the `--tw-gradient-*` declaration.
112
+ * @param themeVars Per-scheme var table for resolving `var(--color-x)`.
85
113
  * @returns Gradient info, or null.
86
114
  */
87
- function fromColor(role: 'from' | 'via' | 'to', tokens: readonly TokenOrValue[] | undefined): GradientAtomInfo | null {
115
+ function fromColor(
116
+ role: 'from' | 'via' | 'to',
117
+ tokens: readonly TokenOrValue[] | undefined,
118
+ themeVars?: ReadonlyMap<string, string>,
119
+ ): GradientAtomInfo | null {
88
120
  if (!tokens) return null
89
121
  for (const token of tokens) {
90
122
  if (token.type !== 'color') continue
@@ -92,7 +124,12 @@ function fromColor(role: 'from' | 'via' | 'to', tokens: readonly TokenOrValue[]
92
124
  if (!color) return null
93
125
  return { role, color } as GradientAtomInfo
94
126
  }
95
- return null
127
+ // Custom @theme token: value is `var(--color-x)` — serialize + resolve.
128
+ let text = serializeTokens(tokens).trim()
129
+ if (themeVars && themeVars.size > 0) text = substituteThemeVars(text, themeVars)
130
+ if (text.length === 0 || text.startsWith('var(')) return null
131
+ const color = resolveGradientColor(text)
132
+ return color ? ({ role, color } as GradientAtomInfo) : null
96
133
  }
97
134
 
98
135
  /**
@@ -45,8 +45,17 @@ export function keyframesName(raw: KeyframesName): string | null {
45
45
  */
46
46
  export function keyframeSelectorOffset(selectors: readonly KeyframeSelector[]): string | null {
47
47
  const [head] = selectors
48
- if (!head) return null
49
- switch (head.type) {
48
+ return head ? offsetForSelector(head) : null
49
+ }
50
+
51
+ /**
52
+ * Render ONE keyframe selector to its CSS offset text, or `null` when it's a
53
+ * timeline-range selector RN can't run.
54
+ * @param selector A single keyframe selector.
55
+ * @returns Offset text (`'from'` / `'to'` / `'50%'`), or `null`.
56
+ */
57
+ function offsetForSelector(selector: KeyframeSelector): string | null {
58
+ switch (selector.type) {
50
59
  case 'from': {
51
60
  return 'from'
52
61
  }
@@ -54,7 +63,7 @@ export function keyframeSelectorOffset(selectors: readonly KeyframeSelector[]):
54
63
  return 'to'
55
64
  }
56
65
  case 'percentage': {
57
- return `${head.value * 100}%`
66
+ return `${selector.value * 100}%`
58
67
  }
59
68
  default: {
60
69
  return null
@@ -62,6 +71,25 @@ export function keyframeSelectorOffset(selectors: readonly KeyframeSelector[]):
62
71
  }
63
72
  }
64
73
 
74
+ /**
75
+ * Render EVERY representable offset in a frame's selector list. Tailwind
76
+ * collapses shared steps into one frame with multiple selectors — `animate-ping`
77
+ * emits `75%, 100% { … }`, `animate-bounce` emits `0%, 100% { … }`. Reading only
78
+ * the first selector (the old singular helper) dropped the terminal `100%`, so
79
+ * the looping animation never defined its end/return-to-start state. Returns all
80
+ * offsets the frame's style applies to; timeline-range selectors are skipped.
81
+ * @param selectors Step selectors for one frame.
82
+ * @returns Every representable offset (may be empty).
83
+ */
84
+ export function keyframeSelectorOffsets(selectors: readonly KeyframeSelector[]): readonly string[] {
85
+ const offsets: string[] = []
86
+ for (const selector of selectors) {
87
+ const offset = offsetForSelector(selector)
88
+ if (offset !== null) offsets.push(offset)
89
+ }
90
+ return offsets
91
+ }
92
+
65
93
  /**
66
94
  * Extract the referenced `@keyframes` name from a declaration whose
67
95
  * property is `animation-name` or a shorthand `animation` that names one.
@@ -1,6 +1,33 @@
1
1
  import type { Declaration as LcDeclaration } from 'lightningcss'
2
2
  import type { RNEntry } from './types'
3
3
 
4
+ /**
5
+ * Lower a CSS `overflow` keyword to one RN's `overflow` prop accepts
6
+ * (`'visible' | 'hidden' | 'scroll'`). `auto` → `scroll` (auto means
7
+ * scroll-when-needed), `clip` → `hidden` (closest no-scroll clip). Anything
8
+ * else (an unexpected keyword) drops so RN never sees an invalid value.
9
+ * @param css CSS overflow keyword.
10
+ * @returns RN overflow keyword, or `null` to drop.
11
+ */
12
+ function mapOverflow(css: string): string | null {
13
+ if (css === 'visible' || css === 'hidden' || css === 'scroll') return css
14
+ if (css === 'auto') return 'scroll'
15
+ if (css === 'clip') return 'hidden'
16
+ return null
17
+ }
18
+
19
+ /**
20
+ * Build the RN `overflow` entry for a raw axis value, mapping it to an
21
+ * RN-valid keyword and dropping unrepresentable shapes. Shared by the
22
+ * `overflow` shorthand and the `overflow-x` / `overflow-y` longhands.
23
+ * @param value Raw per-axis overflow value (string keyword or other).
24
+ * @returns Single `[overflow, …]` entry, or empty when unmappable.
25
+ */
26
+ function overflowEntry(value: unknown): readonly RNEntry[] {
27
+ const mapped = typeof value === 'string' ? mapOverflow(value) : null
28
+ return mapped === null ? [] : [['overflow', mapped]]
29
+ }
30
+
4
31
  /**
5
32
  * Lower CSS alignment keywords to the strings RN accepts. CSS uses
6
33
  * `start`/`end` while RN sticks with the legacy `flex-start`/`flex-end`.
@@ -87,14 +114,10 @@ export function dispatchLayoutDeclaration(decl: LcDeclaration): readonly RNEntry
87
114
  }
88
115
  case 'overflow': {
89
116
  // Lightningcss splits CSS `overflow` into `{x, y}` axes; RN only
90
- // supports a single `overflow` keyword (and only `'hidden' |
91
- // 'visible' | 'scroll'` on iOS, `'hidden' | 'visible'` on
92
- // Android RN ignores unsupported keywords at runtime). Take
93
- // the `x` axis when the user wrote shorthand; per-axis Tailwind
94
- // utilities both emit shorthand here so axis splitting is rare.
95
- const value = decl.value as { x?: unknown; y?: unknown }
96
- if (typeof value.x !== 'string') return []
97
- return [['overflow', value.x]]
117
+ // supports a single `overflow` keyword. Take the `x` axis when the
118
+ // user wrote shorthand; per-axis Tailwind utilities both emit
119
+ // shorthand here so axis splitting is rare.
120
+ return overflowEntry((decl.value as { x?: unknown }).x)
98
121
  }
99
122
  case 'overflow-x':
100
123
  case 'overflow-y': {
@@ -102,7 +125,7 @@ export function dispatchLayoutDeclaration(decl: LcDeclaration): readonly RNEntry
102
125
  // not the `overflow` shorthand. RN has only a single `overflow`,
103
126
  // so collapse both axes onto it (last one declared wins via the
104
127
  // normal entry-merge order).
105
- return typeof decl.value === 'string' ? [['overflow', decl.value]] : []
128
+ return overflowEntry(decl.value)
106
129
  }
107
130
  default: {
108
131
  return null
@@ -69,11 +69,28 @@ export function lengthToPx(length: LengthValue): number {
69
69
  * @returns Number, percent string, or `null` when unrepresentable.
70
70
  */
71
71
  export function dimensionPercentageToNumber(value: DimensionPercentage): LengthResult {
72
- if (value.type === 'dimension') return lengthToPx(value.value)
72
+ if (value.type === 'dimension') return viewportToPercent(value.value) ?? lengthToPx(value.value)
73
73
  if (value.type === 'percentage') return `${roundFloat(value.value * 100)}%`
74
74
  return null
75
75
  }
76
76
 
77
+ /** Viewport-relative units — can't resolve to px statically; approximate as `%`. */
78
+ const VIEWPORT_UNITS: ReadonlySet<string> = new Set(['vw', 'vh', 'vmin', 'vmax', 'dvw', 'dvh', 'svw', 'svh', 'lvw', 'lvh'])
79
+
80
+ /**
81
+ * Map a viewport-unit length to a percentage string — `100vw`/`100vh`
82
+ * (e.g. `w-screen`/`h-screen`) become `'100%'`. Viewport units can't be
83
+ * statically resolved to px (they need live `Dimensions`), and `'N%'` is
84
+ * the closest RN-renderable approximation, far better than treating `100vw`
85
+ * as a literal `100px` box. Returns null for non-viewport units.
86
+ * @param length Typed length value.
87
+ * @returns `'N%'` string, or null when not a viewport unit.
88
+ */
89
+ function viewportToPercent(length: LengthValue): string | null {
90
+ if (!VIEWPORT_UNITS.has(length.unit)) return null
91
+ return `${roundFloat(length.value)}%`
92
+ }
93
+
77
94
  /**
78
95
  * Convert `LengthPercentageOrAuto` (per-side value type for padding /
79
96
  * margin / inset) to an RN scalar. `auto` maps to the string `'auto'`,
@@ -53,10 +53,16 @@ function detectSafeAreaMarker(tokens: readonly TokenOrValue[], themeVars?: Theme
53
53
  const nonWs = stripWhitespace(tokens)
54
54
  if (nonWs.length === 0) return null
55
55
 
56
- // Shape 1: pure env(safe-area-inset-*)
56
+ // Shape 1: pure env(safe-area-inset-*), optionally with a CSS fallback —
57
+ // `env(safe-area-inset-top, 12px)`. The fallback is the author's intended
58
+ // floor when the inset is unavailable, so capture it as `or` (max(inset, N))
59
+ // instead of silently dropping it and collapsing to 0.
57
60
  if (nonWs.length === 1) {
58
61
  const side = envSide(nonWs[0]!)
59
- if (side !== null) return { __safe: side }
62
+ if (side !== null) {
63
+ const or = envFallbackPx(nonWs[0]!, themeVars)
64
+ return or === null ? { __safe: side } : { __safe: side, or }
65
+ }
60
66
  }
61
67
 
62
68
  // Shape 2 / 3: max(env(...), n) or calc(env(...) + n)
@@ -194,6 +200,21 @@ function envSide(token: TokenOrValue): SafeAreaMarker['__safe'] | null {
194
200
  return SIDE_TAG[name.value] ?? null
195
201
  }
196
202
 
203
+ /**
204
+ * Read an `env(side, <fallback>)` token's CSS fallback as a pixel floor.
205
+ * Returns null when there's no fallback or it isn't a plain length — the
206
+ * caller then emits the bare marker.
207
+ * @param token One TokenOrValue (expected to be an `env` token).
208
+ * @param themeVars Optional theme-vars table for resolving `var()` in the fallback.
209
+ * @returns Fallback length in px, or null.
210
+ */
211
+ function envFallbackPx(token: TokenOrValue, themeVars: ThemeVars | undefined): number | null {
212
+ if (token.type !== 'env') return null
213
+ const { fallback } = token.value
214
+ if (!fallback || fallback.length === 0) return null
215
+ return coerceLengthPx(stripWhitespace(fallback), themeVars)
216
+ }
217
+
197
218
  /**
198
219
  * Drop whitespace / comment tokens from a list so downstream branches
199
220
  * can pattern-match by index without counting spaces.
@@ -40,11 +40,18 @@ interface WalkStep {
40
40
  * @param start Start index of the block body (0 for top-level).
41
41
  * @param scheme Active scheme name for declarations inside this scope.
42
42
  * @param table Destination table, mutated in place.
43
+ * @param schemeClasses Selector-class → scheme-name map (from `@custom-variant` selectors).
43
44
  */
44
- function walkBlocks(source: string, start: number, scheme: string, table: ThemeSchemeTable): void {
45
+ function walkBlocks(
46
+ source: string,
47
+ start: number,
48
+ scheme: string,
49
+ table: ThemeSchemeTable,
50
+ schemeClasses: ReadonlyMap<string, string>,
51
+ ): void {
45
52
  let index = start
46
53
  while (index < source.length) {
47
- const step = nextWalkStep(source, index, scheme, table)
54
+ const step = nextWalkStep(source, index, scheme, table, schemeClasses)
48
55
  if (step.next === -1) return
49
56
  index = step.next
50
57
  }
@@ -56,9 +63,16 @@ function walkBlocks(source: string, start: number, scheme: string, table: ThemeS
56
63
  * @param index Current scan index.
57
64
  * @param scheme Active scheme name.
58
65
  * @param table Destination table.
66
+ * @param schemeClasses Selector-class → scheme-name map (from `@custom-variant` selectors).
59
67
  * @returns Step descriptor with the next scan index (or -1 for EOF).
60
68
  */
61
- function nextWalkStep(source: string, index: number, scheme: string, table: ThemeSchemeTable): WalkStep {
69
+ function nextWalkStep(
70
+ source: string,
71
+ index: number,
72
+ scheme: string,
73
+ table: ThemeSchemeTable,
74
+ schemeClasses: ReadonlyMap<string, string>,
75
+ ): WalkStep {
62
76
  // Find the next TOP-LEVEL `--` (outside any `( ... )`). That's the only
63
77
  // place a custom-property declaration can start. `--foo` that appears
64
78
  // inside `var(--foo)` or `calc(... --value(integer) ...)` is part of a
@@ -73,7 +87,7 @@ function nextWalkStep(source: string, index: number, scheme: string, table: Them
73
87
  return { next: consumeDeclaration(source, atIndex, scheme, table) }
74
88
  }
75
89
  if (openIndex !== -1 && openIndex < blockClose) {
76
- return { next: enterBlock(source, index, openIndex, scheme, table) }
90
+ return { next: enterBlock(source, index, openIndex, scheme, table, schemeClasses) }
77
91
  }
78
92
  return { next: -1 }
79
93
  }
@@ -126,9 +140,17 @@ function isDeclarationNext(atIndex: number, openIndex: number, blockClose: numbe
126
140
  * @param openIndex Index of the opening brace.
127
141
  * @param scheme Scheme active in the parent scope.
128
142
  * @param table Destination table.
143
+ * @param schemeClasses Selector-class → scheme-name map (from `@custom-variant` selectors).
129
144
  * @returns Index past the matching closing brace.
130
145
  */
131
- function enterBlock(source: string, index: number, openIndex: number, scheme: string, table: ThemeSchemeTable): number {
146
+ function enterBlock(
147
+ source: string,
148
+ index: number,
149
+ openIndex: number,
150
+ scheme: string,
151
+ table: ThemeSchemeTable,
152
+ schemeClasses: ReadonlyMap<string, string>,
153
+ ): number {
132
154
  const header = source.slice(index, openIndex).trim()
133
155
  // Skip blocks that define utilities / at-rules that carry declarations
134
156
  // meant for a downstream compiler, not custom-property values for the
@@ -136,11 +158,55 @@ function enterBlock(source: string, index: number, openIndex: number, scheme: st
136
158
  // `--value(...)` meta-syntax which would otherwise confuse the
137
159
  // top-level declaration walker and spill into the extracted theme.
138
160
  if (isNonThemeAtRule(header)) return skipMatchingBrace(source, openIndex + 1)
139
- const childScheme = variantNameOf(header) ?? scheme
140
- walkBlocks(source, openIndex + 1, childScheme, table)
161
+ // Scheme scope switches on EITHER an `@variant <name> {` block OR a plain
162
+ // selector block targeting a scheme class (`.dark { }` Tailwind v4's
163
+ // standard dark-mode shape, often wrapped in `@layer base`). Without the
164
+ // selector case, `.dark`'s overrides poured into `base` and overwrote the
165
+ // light defaults, so every scheme rendered the dark values.
166
+ const childScheme = variantNameOf(header) ?? schemeFromSelector(header, schemeClasses) ?? scheme
167
+ walkBlocks(source, openIndex + 1, childScheme, table, schemeClasses)
141
168
  return skipMatchingBrace(source, openIndex + 1)
142
169
  }
143
170
 
171
+ /**
172
+ * Map a plain selector block header to the scheme it overrides, using the
173
+ * class → scheme map derived from `@custom-variant` declarations. Returns the
174
+ * first scheme class found in the selector (`.dark { … }` → `dark`,
175
+ * `.scheme-dark, .scheme-dark * { … }` → `dark`), or null for at-rules and
176
+ * non-scheme selectors (which keep the parent scheme).
177
+ * @param header Block header text (the selector before the `{`).
178
+ * @param schemeClasses Class-name → scheme-name map.
179
+ * @returns Scheme name, or null.
180
+ */
181
+ function schemeFromSelector(header: string, schemeClasses: ReadonlyMap<string, string>): string | null {
182
+ if (header.startsWith('@') || schemeClasses.size === 0) return null
183
+ for (const match of header.matchAll(CLASS_IN_SELECTOR)) {
184
+ const mapped = schemeClasses.get(match[1]!)
185
+ if (mapped) return mapped
186
+ }
187
+ return null
188
+ }
189
+
190
+ /**
191
+ * Build a `<selector-class> → <scheme-name>` map from every
192
+ * `@custom-variant <name> (<class-selector>);` declaration. Unlike
193
+ * {@link extractSchemeAliases} this KEEPS the `class === scheme` case
194
+ * (`.dark → dark`) — that's exactly the mapping needed to attribute a
195
+ * `.dark { … }` override block to the dark scheme.
196
+ * @param css Pre-stripped CSS source.
197
+ * @returns Class-name → scheme-name map.
198
+ */
199
+ function buildSchemeClassMap(css: string): Map<string, string> {
200
+ const map = new Map<string, string>()
201
+ for (const match of css.matchAll(CUSTOM_VARIANT_WITH_SELECTOR)) {
202
+ const scheme = match[1]!
203
+ const selector = match[2]!
204
+ if (!isSchemeSelector(selector)) continue
205
+ for (const cls of selector.matchAll(CLASS_IN_SELECTOR)) map.set(cls[1]!, scheme)
206
+ }
207
+ return map
208
+ }
209
+
144
210
  /**
145
211
  * Whether a block header belongs to an at-rule whose body should be
146
212
  * ignored by the theme-var extractor. `@utility` / `@media` / `@keyframes`
@@ -351,7 +417,8 @@ export const BASE_SCHEME = 'base'
351
417
  export function extractThemeVars(css: string): ThemeSchemeTable {
352
418
  const table: ThemeSchemeTable = new Map()
353
419
  const stripped = stripComments(css)
354
- walkBlocks(stripped, 0, BASE_SCHEME, table)
420
+ const schemeClasses = buildSchemeClassMap(stripped)
421
+ walkBlocks(stripped, 0, BASE_SCHEME, table, schemeClasses)
355
422
  return table
356
423
  }
357
424