rnwind 0.0.11 → 0.0.12

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 (96) hide show
  1. package/lib/cjs/core/normalize-classname.cjs +3 -1
  2. package/lib/cjs/core/normalize-classname.cjs.map +1 -1
  3. package/lib/cjs/core/parser/border-dispatcher.cjs +20 -10
  4. package/lib/cjs/core/parser/border-dispatcher.cjs.map +1 -1
  5. package/lib/cjs/core/parser/color-properties-dispatcher.cjs +7 -5
  6. package/lib/cjs/core/parser/color-properties-dispatcher.cjs.map +1 -1
  7. package/lib/cjs/core/parser/color.cjs +194 -10
  8. package/lib/cjs/core/parser/color.cjs.map +1 -1
  9. package/lib/cjs/core/parser/color.d.ts +18 -3
  10. package/lib/cjs/core/parser/declaration.cjs +62 -4
  11. package/lib/cjs/core/parser/declaration.cjs.map +1 -1
  12. package/lib/cjs/core/parser/layout-dispatcher.cjs +32 -2
  13. package/lib/cjs/core/parser/layout-dispatcher.cjs.map +1 -1
  14. package/lib/cjs/core/parser/shorthand.cjs +10 -3
  15. package/lib/cjs/core/parser/shorthand.cjs.map +1 -1
  16. package/lib/cjs/core/parser/tokens.cjs +9 -0
  17. package/lib/cjs/core/parser/tokens.cjs.map +1 -1
  18. package/lib/cjs/core/parser/tw-parser.cjs +6 -0
  19. package/lib/cjs/core/parser/tw-parser.cjs.map +1 -1
  20. package/lib/cjs/core/parser/typography-dispatcher.cjs +15 -8
  21. package/lib/cjs/core/parser/typography-dispatcher.cjs.map +1 -1
  22. package/lib/cjs/core/style-builder/union-builder.cjs +81 -2
  23. package/lib/cjs/core/style-builder/union-builder.cjs.map +1 -1
  24. package/lib/cjs/core/style-builder/union-builder.d.ts +28 -0
  25. package/lib/cjs/metro/state.cjs +74 -13
  26. package/lib/cjs/metro/state.cjs.map +1 -1
  27. package/lib/cjs/metro/state.d.ts +18 -0
  28. package/lib/cjs/metro/transformer.cjs +10 -4
  29. package/lib/cjs/metro/transformer.cjs.map +1 -1
  30. package/lib/cjs/metro/with-config.cjs +57 -0
  31. package/lib/cjs/metro/with-config.cjs.map +1 -1
  32. package/lib/cjs/metro/with-config.d.ts +12 -0
  33. package/lib/cjs/metro/wrap-imports.cjs +36 -1
  34. package/lib/cjs/metro/wrap-imports.cjs.map +1 -1
  35. package/lib/cjs/runtime/hooks/use-scheme.cjs +14 -7
  36. package/lib/cjs/runtime/hooks/use-scheme.cjs.map +1 -1
  37. package/lib/cjs/runtime/resolve.cjs +6 -2
  38. package/lib/cjs/runtime/resolve.cjs.map +1 -1
  39. package/lib/cjs/runtime/resolve.d.ts +5 -1
  40. package/lib/esm/core/normalize-classname.mjs +3 -1
  41. package/lib/esm/core/normalize-classname.mjs.map +1 -1
  42. package/lib/esm/core/parser/border-dispatcher.mjs +21 -11
  43. package/lib/esm/core/parser/border-dispatcher.mjs.map +1 -1
  44. package/lib/esm/core/parser/color-properties-dispatcher.mjs +8 -6
  45. package/lib/esm/core/parser/color-properties-dispatcher.mjs.map +1 -1
  46. package/lib/esm/core/parser/color.d.ts +18 -3
  47. package/lib/esm/core/parser/color.mjs +195 -12
  48. package/lib/esm/core/parser/color.mjs.map +1 -1
  49. package/lib/esm/core/parser/declaration.mjs +63 -5
  50. package/lib/esm/core/parser/declaration.mjs.map +1 -1
  51. package/lib/esm/core/parser/layout-dispatcher.mjs +32 -2
  52. package/lib/esm/core/parser/layout-dispatcher.mjs.map +1 -1
  53. package/lib/esm/core/parser/shorthand.mjs +11 -4
  54. package/lib/esm/core/parser/shorthand.mjs.map +1 -1
  55. package/lib/esm/core/parser/tokens.mjs +10 -1
  56. package/lib/esm/core/parser/tokens.mjs.map +1 -1
  57. package/lib/esm/core/parser/tw-parser.mjs +6 -0
  58. package/lib/esm/core/parser/tw-parser.mjs.map +1 -1
  59. package/lib/esm/core/parser/typography-dispatcher.mjs +15 -8
  60. package/lib/esm/core/parser/typography-dispatcher.mjs.map +1 -1
  61. package/lib/esm/core/style-builder/union-builder.d.ts +28 -0
  62. package/lib/esm/core/style-builder/union-builder.mjs +82 -3
  63. package/lib/esm/core/style-builder/union-builder.mjs.map +1 -1
  64. package/lib/esm/metro/state.d.ts +18 -0
  65. package/lib/esm/metro/state.mjs +75 -14
  66. package/lib/esm/metro/state.mjs.map +1 -1
  67. package/lib/esm/metro/transformer.mjs +10 -4
  68. package/lib/esm/metro/transformer.mjs.map +1 -1
  69. package/lib/esm/metro/with-config.d.ts +12 -0
  70. package/lib/esm/metro/with-config.mjs +58 -2
  71. package/lib/esm/metro/with-config.mjs.map +1 -1
  72. package/lib/esm/metro/wrap-imports.mjs +36 -1
  73. package/lib/esm/metro/wrap-imports.mjs.map +1 -1
  74. package/lib/esm/runtime/hooks/use-scheme.mjs +14 -7
  75. package/lib/esm/runtime/hooks/use-scheme.mjs.map +1 -1
  76. package/lib/esm/runtime/resolve.d.ts +5 -1
  77. package/lib/esm/runtime/resolve.mjs +6 -2
  78. package/lib/esm/runtime/resolve.mjs.map +1 -1
  79. package/package.json +1 -1
  80. package/src/core/normalize-classname.ts +4 -1
  81. package/src/core/parser/border-dispatcher.ts +22 -11
  82. package/src/core/parser/color-properties-dispatcher.ts +7 -5
  83. package/src/core/parser/color.ts +182 -11
  84. package/src/core/parser/declaration.ts +61 -5
  85. package/src/core/parser/layout-dispatcher.ts +34 -2
  86. package/src/core/parser/shorthand.ts +9 -3
  87. package/src/core/parser/tokens.ts +10 -1
  88. package/src/core/parser/tw-parser.ts +5 -0
  89. package/src/core/parser/typography-dispatcher.ts +15 -6
  90. package/src/core/style-builder/union-builder.ts +83 -3
  91. package/src/metro/state.ts +117 -12
  92. package/src/metro/transformer.ts +9 -4
  93. package/src/metro/with-config.ts +59 -1
  94. package/src/metro/wrap-imports.ts +36 -1
  95. package/src/runtime/hooks/use-scheme.ts +13 -6
  96. package/src/runtime/resolve.ts +6 -2
@@ -1,5 +1,5 @@
1
1
  import type { CssColor, LABColor } from 'lightningcss'
2
- import { formatHex, rgb as culoriRgb } from 'culori'
2
+ import { formatHex, interpolate, rgb as culoriRgb } from 'culori'
3
3
 
4
4
  /**
5
5
  * Clamp a 0-255 float to the integer byte range RN color strings accept.
@@ -136,35 +136,196 @@ function xyzToHex(color: { type: 'xyz-d50' | 'xyz-d65'; x: number; y: number; z:
136
136
  return withAlpha(formatHex({ mode, x: color.x, y: color.y, z: color.z }) ?? null, color.alpha)
137
137
  }
138
138
 
139
+ /**
140
+ * Map a CSS `color-mix(in <space>, …)` interpolation space to the culori mode
141
+ * key culori's {@link interpolate} understands. `srgb` is culori's `rgb`;
142
+ * `srgb-linear` is `lrgb`; the lab/lch/oklab/oklch/hsl/hwb spaces share their
143
+ * CSS name. Unknown spaces fall back to `rgb` so a mix still resolves to a
144
+ * concrete color rather than leaking the raw expression.
145
+ * @param space Lowercased interpolation-space token (after `in `).
146
+ * @returns culori interpolation mode key.
147
+ */
148
+ function colorMixModeFor(space: string): string {
149
+ if (space === 'srgb') return 'rgb'
150
+ if (space === 'srgb-linear') return 'lrgb'
151
+ const known = new Set(['oklab', 'oklch', 'lab', 'lch', 'hsl', 'hwb', 'xyz', 'xyz-d50', 'xyz-d65'])
152
+ return known.has(space) ? space : 'rgb'
153
+ }
154
+
155
+ /**
156
+ * Split a `color-mix()` argument list at top-level commas (parens-aware) so a
157
+ * nested `rgb(0, 0, 0)` color slot doesn't fragment the split.
158
+ * @param body Text between the outer `color-mix(` parentheses.
159
+ * @returns Comma-separated argument fragments (trimmed).
160
+ */
161
+ function splitColorMixArgs(body: string): string[] {
162
+ const parts: string[] = []
163
+ let depth = 0
164
+ let start = 0
165
+ for (let index = 0; index < body.length; index += 1) {
166
+ const ch = body[index]
167
+ if (ch === '(') depth += 1
168
+ else if (ch === ')') depth -= 1
169
+ else if (ch === ',' && depth === 0) {
170
+ parts.push(body.slice(start, index).trim())
171
+ start = index + 1
172
+ }
173
+ }
174
+ parts.push(body.slice(start).trim())
175
+ return parts
176
+ }
177
+
178
+ /**
179
+ * Peel an optional trailing `<num>%` weight off a `color-mix()` color slot.
180
+ * `#ff0000 50%` → `{ color: '#ff0000', weight: 0.5 }`; a bare color → weight
181
+ * `null` (caller fills the complement / defaults to 50/50).
182
+ * @param slot One color argument (color text, optionally suffixed with a percentage).
183
+ * @returns Color text plus its 0–1 weight, or null weight when unspecified.
184
+ */
185
+ function parseColorMixSlot(slot: string): { color: string; weight: number | null } {
186
+ const trimmed = slot.trim()
187
+ if (!trimmed.endsWith('%')) return { color: trimmed, weight: null }
188
+ // End-anchored `<num>%` matcher (no leading `.*?` — avoids the super-linear
189
+ // backtracking ESLint flags). Split the color off at the last whitespace
190
+ // before the percentage token.
191
+ const pct = COLOR_MIX_SLOT_PCT.exec(trimmed)
192
+ if (!pct) return { color: trimmed, weight: null }
193
+ const color = trimmed.slice(0, pct.index).trim()
194
+ if (color.length === 0) return { color: trimmed, weight: null }
195
+ const weight = Number(pct[1]) / 100
196
+ return { color, weight: Number.isFinite(weight) ? weight : null }
197
+ }
198
+
199
+ /** End-anchored `<num>%` matcher for slicing a color-mix slot's weight off its right edge. No backtracking. */
200
+
201
+ const COLOR_MIX_SLOT_PCT = /\s(-?\d+(?:\.\d+)?)%$/
202
+
203
+ /**
204
+ * Resolve a two-color CSS `color-mix(in <space>, A [p1%], B [p2%])` to a
205
+ * concrete sRGB color via culori's {@link interpolate}. CSS weight rules:
206
+ * with one percentage the other fills the complement; with none it is 50/50;
207
+ * with both, the interpolation point is `p2 / (p1 + p2)`. RN can't evaluate
208
+ * `color-mix()` at paint time, so this is the only path that lowers it.
209
+ * @param text Trimmed CSS value beginning with `color-mix(`.
210
+ * @returns sRGB hex/rgba string, or null when the shape/colors can't resolve.
211
+ */
212
+ function resolveColorMix(text: string): string | null {
213
+ if (!text.endsWith(')')) return null
214
+ const open = text.indexOf('(')
215
+ if (open === -1) return null
216
+ const args = splitColorMixArgs(text.slice(open + 1, -1))
217
+ if (args.length !== 3) return null
218
+ const spaceClause = args[0]!.toLowerCase()
219
+ if (!spaceClause.startsWith('in ')) return null
220
+ const mode = colorMixModeFor(spaceClause.slice(3).trim())
221
+ const first = parseColorMixSlot(args[1]!)
222
+ const second = parseColorMixSlot(args[2]!)
223
+ if (first.color.length === 0 || second.color.length === 0) return null
224
+ const point = colorMixPoint(first.weight, second.weight)
225
+ if (point === null) return null
226
+ try {
227
+ const mixed = interpolate([first.color, second.color], mode as never)(point)
228
+ if (!mixed) return null
229
+ const back = culoriRgb(mixed) as { r?: number; g?: number; b?: number; alpha?: number } | undefined
230
+ if (!back || ![back.r, back.g, back.b].every((v) => typeof v === 'number' && Number.isFinite(v))) return null
231
+ const alpha = typeof back.alpha === 'number' ? back.alpha : 1
232
+ return rgbIntsToString(clampByte(back.r! * 255), clampByte(back.g! * 255), clampByte(back.b! * 255), alpha)
233
+ } catch {
234
+ // culori threw on an unparseable color slot — drop rather than leak the raw string.
235
+ return null
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Compute the 0–1 interpolation point (weight of the SECOND color) from the
241
+ * two optional `color-mix()` weights, applying CSS normalization.
242
+ * @param firstWeight 0–1 weight of color A, or null when unspecified.
243
+ * @param secondWeight 0–1 weight of color B, or null when unspecified.
244
+ * @returns Interpolation point in `[0, 1]`, or null when both weights are 0.
245
+ */
246
+ function colorMixPoint(firstWeight: number | null, secondWeight: number | null): number | null {
247
+ if (firstWeight === null && secondWeight === null) return 0.5
248
+ if (firstWeight !== null && secondWeight === null) return 1 - firstWeight
249
+ if (firstWeight === null && secondWeight !== null) return secondWeight
250
+ const sum = firstWeight! + secondWeight!
251
+ if (sum === 0) return null
252
+ return secondWeight! / sum
253
+ }
254
+
255
+ /**
256
+ * CSS-wide cascade keywords that resolve a property against the inherited /
257
+ * initial / previous-layer value at paint time. React Native has NO color
258
+ * cascade — there is no inherited `color` for an arbitrary style prop and no
259
+ * cascade layers — so as a `color` / `backgroundColor` / `borderColor` value
260
+ * every one of these reaches RN as an invalid color string. `currentColor`
261
+ * belongs here too: it resolves to the element's inherited `color`, which RN
262
+ * never threads into other color props. The color path must DROP these (omit
263
+ * the key) rather than leak the keyword. NOTE: `transparent` is NOT here — it
264
+ * is a real color that {@link cssColorToString} / {@link normalizeColorString}
265
+ * lower to `rgba(0, 0, 0, 0)`, which RN paints correctly.
266
+ */
267
+ const CSS_WIDE_COLOR_KEYWORDS: ReadonlySet<string> = new Set([
268
+ 'currentcolor',
269
+ 'inherit',
270
+ 'initial',
271
+ 'unset',
272
+ 'revert',
273
+ 'revert-layer',
274
+ ])
275
+
139
276
  /**
140
277
  * Modern CSS color functions RN's native view manager can't paint —
141
278
  * 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.
279
+ * `transparent`) RN reads directly and must pass through untouched. The
280
+ * CSS-wide cascade keywords (`currentColor`, `inherit`, …) are NOT readable
281
+ * they have no RN equivalent and are dropped via {@link isCssWideColorKeyword}.
282
+ * Custom `@theme` tokens reach the parser as `var(--color-x)` (only the default
283
+ * palette is `theme(inline)`-d), so they flow through the unparsed-string path
284
+ * where the typed {@link cssColorToString} never runs — this is the one place
285
+ * that lowers their wide-gamut values to sRGB. `color-mix(` is in the list too,
286
+ * but it takes the dedicated {@link resolveColorMix} path — culori's `rgb()`
287
+ * parser can't read it.
147
288
  */
148
- const RN_UNREADABLE_COLOR_PREFIXES: readonly string[] = ['oklch(', 'oklab(', 'lab(', 'lch(', 'color(', 'hwb(']
289
+ const RN_UNREADABLE_COLOR_PREFIXES: readonly string[] = ['oklch(', 'oklab(', 'lab(', 'lch(', 'color(', 'hwb(', 'color-mix(']
149
290
 
150
291
  /**
151
292
  * 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.
293
+ * `color(display-p3 …)`, `color-mix(…)`) to an sRGB hex/rgba string RN can
294
+ * paint. Returns `null` for anything RN already understands (hex, rgb, hsl,
295
+ * named) so the caller keeps the original text — only the unrepresentable
296
+ * forms convert. `color-mix()` is resolved via culori's interpolator; when it
297
+ * (or any other modern form) can't resolve, returns null so the caller DROPS
298
+ * the value rather than leaking the raw, RN-unreadable string.
155
299
  * Mirrors {@link cssColorToString}'s culori lowering for the string path.
156
300
  * @param text Resolved CSS color text (post theme-var substitution).
157
301
  * @returns sRGB color string, or `null` when no conversion is needed/possible.
158
302
  */
159
303
  export function normalizeColorString(text: string): string | null {
160
- const lower = text.trim().toLowerCase()
304
+ const trimmed = text.trim()
305
+ const lower = trimmed.toLowerCase()
161
306
  if (!RN_UNREADABLE_COLOR_PREFIXES.some((prefix) => lower.startsWith(prefix))) return null
307
+ if (lower.startsWith('color-mix(')) return resolveColorMix(trimmed)
162
308
  const parsed = culoriRgb(text)
163
309
  if (!parsed || ![parsed.r, parsed.g, parsed.b].every((v) => typeof v === 'number' && Number.isFinite(v))) return null
164
310
  const alpha = typeof parsed.alpha === 'number' ? parsed.alpha : 1
165
311
  return rgbIntsToString(clampByte(parsed.r * 255), clampByte(parsed.g * 255), clampByte(parsed.b * 255), alpha)
166
312
  }
167
313
 
314
+ /**
315
+ * Whether a resolved color STRING is a CSS-wide cascade keyword
316
+ * (`currentColor`, `inherit`, `initial`, `unset`, `revert`, `revert-layer`)
317
+ * with no React Native equivalent. RN has no color cascade, so the color path
318
+ * must DROP (omit the key) when this is true rather than emit the keyword —
319
+ * RN would otherwise receive an invalid color string and render nothing.
320
+ * `transparent` is NOT a cascade keyword: it is a concrete color the converters
321
+ * lower to `rgba(0, 0, 0, 0)`, so it returns false here and resolves normally.
322
+ * @param text Resolved color text (post theme-var substitution / typed-color stringification).
323
+ * @returns True when the value is an RN-unrepresentable CSS-wide keyword.
324
+ */
325
+ export function isCssWideColorKeyword(text: string): boolean {
326
+ return CSS_WIDE_COLOR_KEYWORDS.has(text.trim().toLowerCase())
327
+ }
328
+
168
329
  /**
169
330
  * Convert a lightningcss `CssColor` to an RN-safe color string. RGB
170
331
  * passes through unchanged. LAB / LCH / OKLAB / OKLCH / `color(xyz-…)`
@@ -213,6 +374,16 @@ export function cssColorToString(color: CssColor): string {
213
374
  return 'currentColor'
214
375
  }
215
376
  case 'light-dark': {
377
+ // `light-dark(L, D)` is a RUNTIME CSS function — the browser picks the
378
+ // branch from the element's `color-scheme`. rnwind has no runtime CSS
379
+ // evaluation, and the active scheme is NOT threaded into this typed
380
+ // converter (it takes a bare `CssColor`, and every one of its ~15 call
381
+ // sites — border / shorthand / gradient / declaration dispatchers — calls
382
+ // it without a scheme). Scheme resolution instead happens UPSTREAM, at the
383
+ // CSS-block walk (`@custom-variant` + `.dark {}` selectors in
384
+ // theme-vars.ts), which compiles a separate atom + var table per scheme.
385
+ // So the `.light` branch is the correct compile-time default here; the
386
+ // dark value is carried by the scheme-specific atom, not by this function.
216
387
  return cssColorToString(color.light)
217
388
  }
218
389
  default: {
@@ -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, normalizeColorString } from './color'
4
+ import { cssColorToString, isCssWideColorKeyword, normalizeColorString } from './color'
5
5
  import { dimensionPercentageToNumber, gapValueToValue, lengthPercentageOrAutoToValue, sizeLikeToValue } from './length'
6
6
  import {
7
7
  expandBorderColor,
@@ -57,6 +57,10 @@ const RN_UNSUPPORTED_PROPERTIES: ReadonlySet<string> = new Set([
57
57
  'clear',
58
58
  'table-layout',
59
59
  'caption-side',
60
+ // Web table-model props with no RN equivalent. `border-spacing` otherwise
61
+ // reaches the generic fallback and leaks an unresolved `calc(0.25rem * N)`.
62
+ 'border-spacing',
63
+ 'border-collapse',
60
64
  'transform-style',
61
65
  'background-blend-mode',
62
66
  'scroll-behavior',
@@ -73,6 +77,20 @@ const RN_UNSUPPORTED_PROPERTIES: ReadonlySet<string> = new Set([
73
77
  'field-sizing',
74
78
  'forced-color-adjust',
75
79
  'text-shadow',
80
+ // Web-only KEYS RN has no style prop for. `order` leaks through the negative
81
+ // variant (`-order-1` → `order: calc(1 * -1)` unparsed → resolves to `-1`);
82
+ // the positive `order-*` already drops since no typed branch claims it. Adding
83
+ // it here drops BOTH signs. `isolation` (`isolate` / `isolation-auto`) reaches
84
+ // the `custom` path as `isolation: isolate|auto` — also no RN equivalent.
85
+ 'order',
86
+ 'isolation',
87
+ // `normal-nums` reaches the `custom` path as `font-variant-numeric: normal`
88
+ // and leaked the non-RN key `fontVariantNumeric`. RN expresses numeric
89
+ // variants via the `fontVariant` array, not this property — drop it. (The
90
+ // `tabular-nums`/`oldstyle-nums`/… utilities carry their token in dropped
91
+ // `--tw-numeric-*` vars and already resolve to {}; mapping those to
92
+ // `fontVariant` is a tracked future enhancement, not a leak.)
93
+ 'font-variant-numeric',
76
94
  'touch-action',
77
95
  'backdrop-filter',
78
96
  '-webkit-backdrop-filter',
@@ -82,6 +100,24 @@ const RN_UNSUPPORTED_PROPERTIES: ReadonlySet<string> = new Set([
82
100
  '-moz-osx-font-smoothing',
83
101
  ])
84
102
 
103
+ /**
104
+ * Valid value sets for RN enum style props (keyed by the camelCase RN key).
105
+ * A value outside its prop's set is RN-invalid even when the string itself
106
+ * looks clean — RN ignores or warns on it (`position: 'fixed'`, `display:
107
+ * 'contents'`, `justifyContent: 'stretch' | 'baseline'`, `alignContent:
108
+ * 'normal'`). This is the dimension the leak-shape (`var(`/`calc(`/NaN) check
109
+ * misses. Both the typed `display` / `position` branches AND the generic
110
+ * unparsed fallback consult this — Tailwind routes some keyword-only values
111
+ * (`justify-content: baseline`) through the unparsed channel, which would
112
+ * otherwise emit them via `kebabToCamel` with no enum awareness.
113
+ */
114
+ const RN_ENUM_VALUES: Readonly<Record<string, ReadonlySet<string>>> = {
115
+ position: new Set(['absolute', 'relative', 'static']),
116
+ display: new Set(['flex', 'none']),
117
+ justifyContent: new Set(['flex-start', 'flex-end', 'center', 'space-between', 'space-around', 'space-evenly']),
118
+ alignContent: new Set(['flex-start', 'flex-end', 'center', 'stretch', 'space-between', 'space-around', 'space-evenly']),
119
+ }
120
+
85
121
  /** CSS single-sided logical-inline property → RN writing-direction Yoga key. */
86
122
  const LOGICAL_INLINE_TO_RN: Record<string, string> = {
87
123
  'margin-inline-start': 'marginStart',
@@ -245,6 +281,10 @@ function unparsedToEntries(
245
281
  // space (outside parens — `rgb(1 2 3)` keeps its inner spaces) means it's a
246
282
  // multi-token shorthand, not a color: drop it.
247
283
  if (hasTopLevelSpace(coerced)) return []
284
+ // CSS-wide cascade keywords (`inherit`, `currentColor`, `initial`, `unset`,
285
+ // `revert`, `revert-layer`) have no RN equivalent — RN has no color
286
+ // cascade. Drop rather than leak an invalid color string to RN.
287
+ if (isCssWideColorKeyword(coerced)) return []
248
288
  // Lower modern color spaces (`oklch(…)`, `lab(…)`, `color(p3 …)`) that
249
289
  // RN can't paint to sRGB; hex/rgb/hsl/named pass through unchanged.
250
290
  const color = normalizeColorString(coerced) ?? coerced
@@ -254,7 +294,14 @@ function unparsedToEntries(
254
294
  if (sides) return sides.map((key): RNEntry => [key, color])
255
295
  return [[kebabToCamel(property), color]]
256
296
  }
257
- return [[kebabToCamel(property), coerced]]
297
+ const camelKey = kebabToCamel(property)
298
+ // Enum props whose value Tailwind sometimes routes through the unparsed
299
+ // channel (`justify-content: baseline` → `justifyContent: 'baseline'`),
300
+ // bypassing the typed dispatcher's keyword map. RN rejects values outside
301
+ // the prop's set, so gate them here exactly like the typed branches do.
302
+ const enumValues = RN_ENUM_VALUES[camelKey]
303
+ if (enumValues && typeof coerced === 'string' && !enumValues.has(coerced)) return []
304
+ return [[camelKey, coerced]]
258
305
  }
259
306
 
260
307
  /**
@@ -294,7 +341,12 @@ export function declarationToRnEntries(decl: LcDeclaration, themeVars?: Readonly
294
341
  // `background-color` narrows to `CssColor | 'background'` — the
295
342
  // literal keyword means UA default. Skip the keyword.
296
343
  if (typeof decl.value === 'string') return []
297
- return [[kebabToCamel(decl.property), cssColorToString(decl.value)]]
344
+ const colorString = cssColorToString(decl.value)
345
+ // `currentColor` (lightningcss `{type:'currentcolor'}`) and any other
346
+ // CSS-wide cascade keyword have no RN equivalent — drop instead of
347
+ // leaking the keyword string to RN.
348
+ if (isCssWideColorKeyword(colorString)) return []
349
+ return [[kebabToCamel(decl.property), colorString]]
298
350
  }
299
351
  case 'border-color': {
300
352
  return expandBorderColor(decl.value)
@@ -370,10 +422,14 @@ export function declarationToRnEntries(decl: LcDeclaration, themeVars?: Readonly
370
422
  return [['fontStyle', decl.value.type]]
371
423
  }
372
424
  case 'display': {
373
- return displayToEntries(decl.value)
425
+ // `displayToEntries` can still emit `contents` (a CSS value RN rejects —
426
+ // only `flex` / `none` are valid). Gate the result on the RN-valid set.
427
+ return displayToEntries(decl.value).filter(([, value]) => typeof value === 'string' && RN_ENUM_VALUES.display.has(value))
374
428
  }
375
429
  case 'position': {
376
- return [['position', decl.value.type]]
430
+ // RN `position` accepts only `absolute` / `relative` / `static`; CSS
431
+ // `fixed` / `sticky` are invalid for RN, so drop them.
432
+ return RN_ENUM_VALUES.position.has(decl.value.type) ? [['position', decl.value.type]] : []
377
433
  }
378
434
  case 'font-size': {
379
435
  const px = fontSizeToPx(decl.value)
@@ -1,6 +1,34 @@
1
1
  import type { Declaration as LcDeclaration } from 'lightningcss'
2
2
  import type { RNEntry } from './types'
3
3
 
4
+ /**
5
+ * `justify-content` keywords RN accepts. CSS adds `stretch` / `normal` /
6
+ * `left` / `right` (and `start`/`end`, which we lower to `flex-start`/`flex-end`
7
+ * BEFORE this gate). RN rejects anything outside this set — drop it.
8
+ */
9
+ const RN_JUSTIFY_CONTENT_VALUES: ReadonlySet<string> = new Set([
10
+ 'flex-start',
11
+ 'flex-end',
12
+ 'center',
13
+ 'space-between',
14
+ 'space-around',
15
+ 'space-evenly',
16
+ ])
17
+
18
+ /**
19
+ * `align-content` keywords RN accepts. Differs from `justify-content`: RN
20
+ * allows `stretch` here but rejects `normal`. Drop values outside the set.
21
+ */
22
+ const RN_ALIGN_CONTENT_VALUES: ReadonlySet<string> = new Set([
23
+ 'flex-start',
24
+ 'flex-end',
25
+ 'center',
26
+ 'stretch',
27
+ 'space-between',
28
+ 'space-around',
29
+ 'space-evenly',
30
+ ])
31
+
4
32
  /**
5
33
  * Lower a CSS `overflow` keyword to one RN's `overflow` prop accepts
6
34
  * (`'visible' | 'hidden' | 'scroll'`). `auto` → `scroll` (auto means
@@ -105,12 +133,16 @@ export function dispatchLayoutDeclaration(decl: LcDeclaration): readonly RNEntry
105
133
  return v === null ? [] : [['alignSelf', v]]
106
134
  }
107
135
  case 'align-content': {
136
+ // After CSS→RN lowering, gate on RN's valid set — drops `normal`
137
+ // (`content-normal`) which RN's `alignContent` rejects.
108
138
  const v = mapAlignKeyword(decl.value)
109
- return v === null ? [] : [['alignContent', v]]
139
+ return v === null || !RN_ALIGN_CONTENT_VALUES.has(v) ? [] : [['alignContent', v]]
110
140
  }
111
141
  case 'justify-content': {
142
+ // After CSS→RN lowering, gate on RN's valid set — drops `stretch`
143
+ // (`justify-stretch`) and any other keyword RN's `justifyContent` rejects.
112
144
  const v = mapJustifyKeyword(decl.value)
113
- return v === null ? [] : [['justifyContent', v]]
145
+ return v === null || !RN_JUSTIFY_CONTENT_VALUES.has(v) ? [] : [['justifyContent', v]]
114
146
  }
115
147
  case 'overflow': {
116
148
  // Lightningcss splits CSS `overflow` into `{x, y}` axes; RN only
@@ -10,7 +10,7 @@ import type {
10
10
  PaddingBlock,
11
11
  PaddingInline,
12
12
  } from 'lightningcss'
13
- import { cssColorToString } from './color'
13
+ import { cssColorToString, isCssWideColorKeyword } from './color'
14
14
  import { dimensionPercentageToNumber, gapValueToValue, lengthPercentageOrAutoToValue } from './length'
15
15
  import type { RNEntry } from './types'
16
16
 
@@ -135,13 +135,19 @@ export function expandBorderColor(value: BorderColor): readonly RNEntry[] {
135
135
  const right = cssColorToString(value.right)
136
136
  const bottom = cssColorToString(value.bottom)
137
137
  const left = cssColorToString(value.left)
138
- if (top === right && right === bottom && bottom === left) return [['borderColor', top]]
139
- return [
138
+ // CSS-wide cascade keywords (`currentColor`, `inherit`, ) have no RN
139
+ // equivalent — drop any side that resolves to one rather than leak the
140
+ // keyword as a `borderColor`/`border*Color` value RN can't paint.
141
+ const sides: readonly (readonly [string, string])[] = [
140
142
  ['borderTopColor', top],
141
143
  ['borderRightColor', right],
142
144
  ['borderBottomColor', bottom],
143
145
  ['borderLeftColor', left],
144
146
  ]
147
+ const paintable = sides.filter(([, color]) => !isCssWideColorKeyword(color))
148
+ if (paintable.length === 0) return []
149
+ if (paintable.length === 4 && top === right && right === bottom && bottom === left) return [['borderColor', top]]
150
+ return paintable
145
151
  }
146
152
 
147
153
  /**
@@ -1,7 +1,7 @@
1
1
  import type { Token, TokenOrValue } from 'lightningcss'
2
2
  import { rgb as culoriRgb } from 'culori'
3
3
  import { BARE_NUMBER_REGEX, CALC_MUL_REGEX, CALC_RATIO_REGEX, LENGTH_PX_REGEX, LENGTH_REM_REGEX, REM_TO_PX } from './constants'
4
- import { cssColorToString } from './color'
4
+ import { cssColorToString, normalizeColorString } from './color'
5
5
  import type { RNStyleValue } from './types'
6
6
 
7
7
  /**
@@ -247,6 +247,15 @@ export function coerceUnparsedValue(text: string): RNStyleValue | null {
247
247
  if (rem) return Number(rem[1]) * REM_TO_PX
248
248
  const colorMix = evaluateColorMixWithTransparent(trimmed)
249
249
  if (colorMix !== null) return colorMix
250
+ // Real two-color `color-mix(in <space>, A, B)` (not the `, transparent)`
251
+ // opacity shape) — resolve it to a concrete sRGB color via culori so the
252
+ // raw, RN-unreadable `color-mix(...)` string never reaches the StyleSheet.
253
+ if (trimmed.toLowerCase().startsWith('color-mix(')) {
254
+ // Resolved → concrete color; unresolvable → null (DROP). Either way the
255
+ // raw `color-mix(...)` text must never fall through to the string path
256
+ // below, where RN would receive an unreadable value and render nothing.
257
+ return normalizeColorString(trimmed)
258
+ }
250
259
  const fallback = extractVariableFallback(trimmed)
251
260
  if (fallback !== null) return coerceUnparsedValue(fallback)
252
261
  const calcRatio = CALC_RATIO_REGEX.exec(trimmed)
@@ -1373,6 +1373,11 @@ function parseRgbaExpression(text: string): { color: string; opacity: number } |
1373
1373
  if (typeof alphaText === 'string') {
1374
1374
  opacity = alphaText.endsWith('%') ? Number(alphaText.slice(0, -1)) / 100 : Number(alphaText)
1375
1375
  }
1376
+ // CSS Color 4: a `none` (or otherwise non-numeric) alpha parses to NaN here.
1377
+ // Its used value when compositing is 0 (fully transparent) — and crucially
1378
+ // RN throws on a NaN `shadowOpacity`, so collapse any non-finite alpha to 0
1379
+ // before it can reach a numeric style prop.
1380
+ if (!Number.isFinite(opacity)) opacity = 0
1376
1381
  const hex = `#${[r!, g!, b!]
1377
1382
  .map((n) =>
1378
1383
  Math.max(0, Math.min(255, Math.round(Number(n))))
@@ -6,17 +6,26 @@ import type { RNEntry } from './types'
6
6
  /** RN-supported `textDecorationStyle` values (`wavy` has no RN equivalent). */
7
7
  const RN_DECORATION_STYLES: ReadonlySet<string> = new Set(['solid', 'double', 'dotted', 'dashed'])
8
8
 
9
+ /**
10
+ * The only `textDecorationLine` keywords React Native renders. CSS `overline`
11
+ * has no RN analog, so any line string containing it (or any other unknown
12
+ * keyword) is dropped rather than leaked as a value RN warns on + ignores.
13
+ */
14
+ const RN_DECORATION_LINES: ReadonlySet<string> = new Set(['none', 'underline', 'line-through', 'underline line-through'])
15
+
9
16
  /**
10
17
  * Build the RN `textDecorationLine` entry — string identity for the
11
- * single-line cases, joined-string for the array shape.
18
+ * single-line cases, joined-string for the array shape. Drops any value
19
+ * outside RN's enum (`overline`, `overline underline`, …) so no invalid
20
+ * keyword reaches the StyleSheet.
12
21
  * @param value Typed text-decoration-line.
13
- * @returns Single-entry list with `textDecorationLine`.
22
+ * @returns Single-entry list with a valid `textDecorationLine`, or empty.
14
23
  */
15
24
  function textDecorationLineToEntries(value: LcDeclaration['value']): readonly RNEntry[] {
16
- if (value === 'none') return [['textDecorationLine', 'none']]
17
- if (typeof value === 'string') return [['textDecorationLine', value]]
18
- if (Array.isArray(value)) return [['textDecorationLine', value.join(' ')]]
19
- return []
25
+ if (typeof value === 'string') return RN_DECORATION_LINES.has(value) ? [['textDecorationLine', value]] : []
26
+ if (!Array.isArray(value)) return []
27
+ const line = value.join(' ')
28
+ return RN_DECORATION_LINES.has(line) ? [['textDecorationLine', line]] : []
20
29
  }
21
30
 
22
31
  /**
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomBytes } from 'node:crypto'
2
- import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
3
3
  import path from 'node:path'
4
4
  import type { GradientAtomInfo, HapticRequest, KeyframeBlock, SchemedStyle, TailwindParser } from '../parser'
5
5
  import type { ThemeTables } from '../types'
@@ -8,6 +8,12 @@ import { buildSchemeSources, type AtomSerializedCache } from './build-style'
8
8
  /** Manifest module basename — the file SchemeProvider imports via the resolver. */
9
9
  const MANIFEST_BASENAME = 'schemes.js'
10
10
 
11
+ /** Suffix every per-scheme style file carries on disk. */
12
+ const SCHEME_FILE_SUFFIX = '.style.js'
13
+
14
+ /** Registry key for the always-loaded fallback scheme — never reaped. */
15
+ const COMMON_SCHEME = 'common'
16
+
11
17
  /**
12
18
  * Atomic file write — stage to a `.tmp.<pid>.<nonce>` sibling, then
13
19
  * `rename()` into place. Skips the write entirely when the existing
@@ -66,7 +72,34 @@ function setsEqual(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
66
72
  * @returns Absolute path, e.g. `<cacheDir>/dark.style.js`.
67
73
  */
68
74
  function schemeFilePath(cacheDir: string, scheme: string): string {
69
- return path.join(cacheDir, `${scheme}.style.js`)
75
+ return path.join(cacheDir, `${scheme}${SCHEME_FILE_SUFFIX}`)
76
+ }
77
+
78
+ /**
79
+ * List scheme names whose `<scheme>.style.js` exists on disk but is NOT in
80
+ * the set the current build emits — orphans left by a removed variant
81
+ * (e.g. user drops `@variant dark`, or a theme swap via git pull). The
82
+ * always-loaded `common` scheme is never an orphan. The manifest
83
+ * (`schemes.js`) doesn't carry the `.style.js` suffix, so it's skipped.
84
+ * @param cacheDir Absolute cache directory.
85
+ * @param liveSchemes Scheme names the current build writes.
86
+ * @returns Orphaned scheme names safe to delete.
87
+ */
88
+ function findOrphanedSchemes(cacheDir: string, liveSchemes: ReadonlySet<string>): readonly string[] {
89
+ let names: readonly string[]
90
+ try {
91
+ names = readdirSync(cacheDir)
92
+ } catch {
93
+ return []
94
+ }
95
+ const orphans: string[] = []
96
+ for (const name of names) {
97
+ if (!name.endsWith(SCHEME_FILE_SUFFIX)) continue
98
+ const scheme = name.slice(0, -SCHEME_FILE_SUFFIX.length)
99
+ if (scheme === COMMON_SCHEME || liveSchemes.has(scheme)) continue
100
+ orphans.push(scheme)
101
+ }
102
+ return orphans
70
103
  }
71
104
 
72
105
  /**
@@ -156,6 +189,15 @@ class UnionBuilder {
156
189
  return this.serializedMissesCount
157
190
  }
158
191
 
192
+ /**
193
+ * Snapshot of the scheme keys currently tracked in `schemeSignatures` —
194
+ * exposed for tests to assert orphan-signature cleanup.
195
+ * @returns Scheme signature keys (includes the `__manifest` sentinel).
196
+ */
197
+ public schemeSignatureKeys(): readonly string[] {
198
+ return [...this.schemeSignatures.keys()]
199
+ }
200
+
159
201
  /**
160
202
  * Absolute path of one scheme's style file.
161
203
  * @param scheme Registry key.
@@ -285,7 +327,7 @@ class UnionBuilder {
285
327
  for (const [scheme, source] of Object.entries(schemeSources)) {
286
328
  const signature = signatureOf(source)
287
329
  const target = schemeFilePath(this.cacheDir, scheme)
288
- if (this.schemeSignatures.get(scheme) === signature && existsSync(target)) continue
330
+ if (this.canSkipWrite(scheme, signature, target, source)) continue
289
331
  if (writeIfChanged(target, source)) changed.push(scheme)
290
332
  this.schemeSignatures.set(scheme, signature)
291
333
  }
@@ -297,9 +339,47 @@ class UnionBuilder {
297
339
  this.schemeSignatures.set('__manifest', manifestSignature)
298
340
  }
299
341
 
342
+ this.reapOrphanedSchemes(new Set(Object.keys(schemeSources)))
300
343
  return { changedSchemes: changed }
301
344
  }
302
345
 
346
+ /**
347
+ * Whether the current write for one scheme can be skipped. A skip is
348
+ * safe only when the cached signature matches AND the bytes on disk
349
+ * still equal the expected source — an `existsSync` pass alone would
350
+ * keep a truncated or externally-modified file (corrupt content with a
351
+ * stale-but-matching signature). The byte read happens only on a
352
+ * signature match, so the common no-change path stays cheap.
353
+ * @param scheme Scheme registry key.
354
+ * @param signature Signature of the source about to be written.
355
+ * @param target Absolute path of the scheme file.
356
+ * @param source Expected file content.
357
+ * @returns Whether writing this scheme can be skipped.
358
+ */
359
+ private canSkipWrite(scheme: string, signature: string, target: string, source: string): boolean {
360
+ if (this.schemeSignatures.get(scheme) !== signature) return false
361
+ try {
362
+ return readFileSync(target, 'utf8') === source
363
+ } catch {
364
+ // Missing or unreadable on disk — must rewrite.
365
+ return false
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Delete `<scheme>.style.js` files left behind by a scheme that's no
371
+ * longer part of the build (removed `@variant`, theme swap), and drop
372
+ * their cached signatures so a later re-introduction rewrites cleanly.
373
+ * The `common` file and the manifest are never touched.
374
+ * @param liveSchemes Scheme names the current build wrote.
375
+ */
376
+ private reapOrphanedSchemes(liveSchemes: ReadonlySet<string>): void {
377
+ for (const scheme of findOrphanedSchemes(this.cacheDir, liveSchemes)) {
378
+ rmSync(schemeFilePath(this.cacheDir, scheme), { force: true })
379
+ this.schemeSignatures.delete(scheme)
380
+ }
381
+ }
382
+
303
383
  /**
304
384
  * Ensure the manifest + common scheme files exist on disk so Metro's
305
385
  * resolver can SHA1 them at boot before the first transform runs.