rnwind 0.0.2 → 0.0.3

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 (80) hide show
  1. package/lib/cjs/core/parser/color.cjs +53 -24
  2. package/lib/cjs/core/parser/color.cjs.map +1 -1
  3. package/lib/cjs/core/parser/layout-dispatcher.cjs +20 -0
  4. package/lib/cjs/core/parser/layout-dispatcher.cjs.map +1 -1
  5. package/lib/cjs/core/parser/length.cjs +20 -6
  6. package/lib/cjs/core/parser/length.cjs.map +1 -1
  7. package/lib/cjs/core/parser/length.d.ts +6 -3
  8. package/lib/cjs/core/parser/shorthand.cjs +37 -5
  9. package/lib/cjs/core/parser/shorthand.cjs.map +1 -1
  10. package/lib/cjs/core/parser/shorthand.d.ts +11 -5
  11. package/lib/cjs/core/parser/theme-vars.cjs +53 -0
  12. package/lib/cjs/core/parser/theme-vars.cjs.map +1 -1
  13. package/lib/cjs/core/parser/theme-vars.d.ts +21 -0
  14. package/lib/cjs/core/parser/tokens.cjs +183 -1
  15. package/lib/cjs/core/parser/tokens.cjs.map +1 -1
  16. package/lib/cjs/core/parser/tw-parser.cjs +140 -27
  17. package/lib/cjs/core/parser/tw-parser.cjs.map +1 -1
  18. package/lib/cjs/core/parser/tw-parser.d.ts +21 -5
  19. package/lib/cjs/core/parser/typography-dispatcher.cjs +16 -1
  20. package/lib/cjs/core/parser/typography-dispatcher.cjs.map +1 -1
  21. package/lib/cjs/core/style-builder/build-style.cjs +73 -26
  22. package/lib/cjs/core/style-builder/build-style.cjs.map +1 -1
  23. package/lib/cjs/metro/state.cjs +52 -2
  24. package/lib/cjs/metro/state.cjs.map +1 -1
  25. package/lib/cjs/metro/state.d.ts +17 -1
  26. package/lib/cjs/metro/transform-ast.cjs +238 -21
  27. package/lib/cjs/metro/transform-ast.cjs.map +1 -1
  28. package/lib/cjs/metro/transform-ast.d.ts +15 -0
  29. package/lib/cjs/metro/transformer.cjs +29 -2
  30. package/lib/cjs/metro/transformer.cjs.map +1 -1
  31. package/lib/cjs/metro/with-config.cjs +1 -1
  32. package/lib/cjs/metro/with-config.cjs.map +1 -1
  33. package/lib/cjs/metro/with-config.d.ts +22 -0
  34. package/lib/esm/core/parser/color.mjs +53 -24
  35. package/lib/esm/core/parser/color.mjs.map +1 -1
  36. package/lib/esm/core/parser/layout-dispatcher.mjs +20 -0
  37. package/lib/esm/core/parser/layout-dispatcher.mjs.map +1 -1
  38. package/lib/esm/core/parser/length.d.ts +6 -3
  39. package/lib/esm/core/parser/length.mjs +20 -6
  40. package/lib/esm/core/parser/length.mjs.map +1 -1
  41. package/lib/esm/core/parser/shorthand.d.ts +11 -5
  42. package/lib/esm/core/parser/shorthand.mjs +37 -5
  43. package/lib/esm/core/parser/shorthand.mjs.map +1 -1
  44. package/lib/esm/core/parser/theme-vars.d.ts +21 -0
  45. package/lib/esm/core/parser/theme-vars.mjs +53 -1
  46. package/lib/esm/core/parser/theme-vars.mjs.map +1 -1
  47. package/lib/esm/core/parser/tokens.mjs +183 -1
  48. package/lib/esm/core/parser/tokens.mjs.map +1 -1
  49. package/lib/esm/core/parser/tw-parser.d.ts +21 -5
  50. package/lib/esm/core/parser/tw-parser.mjs +141 -28
  51. package/lib/esm/core/parser/tw-parser.mjs.map +1 -1
  52. package/lib/esm/core/parser/typography-dispatcher.mjs +16 -1
  53. package/lib/esm/core/parser/typography-dispatcher.mjs.map +1 -1
  54. package/lib/esm/core/style-builder/build-style.mjs +73 -26
  55. package/lib/esm/core/style-builder/build-style.mjs.map +1 -1
  56. package/lib/esm/metro/state.d.ts +17 -1
  57. package/lib/esm/metro/state.mjs +51 -3
  58. package/lib/esm/metro/state.mjs.map +1 -1
  59. package/lib/esm/metro/transform-ast.d.ts +15 -0
  60. package/lib/esm/metro/transform-ast.mjs +238 -21
  61. package/lib/esm/metro/transform-ast.mjs.map +1 -1
  62. package/lib/esm/metro/transformer.mjs +30 -3
  63. package/lib/esm/metro/transformer.mjs.map +1 -1
  64. package/lib/esm/metro/with-config.d.ts +22 -0
  65. package/lib/esm/metro/with-config.mjs +1 -1
  66. package/lib/esm/metro/with-config.mjs.map +1 -1
  67. package/package.json +2 -1
  68. package/src/core/parser/color.ts +52 -18
  69. package/src/core/parser/layout-dispatcher.ts +19 -0
  70. package/src/core/parser/length.ts +20 -6
  71. package/src/core/parser/shorthand.ts +35 -5
  72. package/src/core/parser/theme-vars.ts +53 -0
  73. package/src/core/parser/tokens.ts +171 -1
  74. package/src/core/parser/tw-parser.ts +147 -28
  75. package/src/core/parser/typography-dispatcher.ts +15 -1
  76. package/src/core/style-builder/build-style.ts +84 -26
  77. package/src/metro/state.ts +49 -1
  78. package/src/metro/transform-ast.ts +249 -18
  79. package/src/metro/transformer.ts +28 -3
  80. package/src/metro/with-config.ts +23 -1
@@ -1,4 +1,5 @@
1
1
  import type { Token, TokenOrValue } from 'lightningcss'
2
+ import { rgb as culoriRgb } from 'culori'
2
3
  import { BARE_NUMBER_REGEX, CALC_MUL_REGEX, CALC_RATIO_REGEX, LENGTH_PX_REGEX, LENGTH_REM_REGEX, REM_TO_PX } from './constants'
3
4
  import { cssColorToString } from './color'
4
5
  import type { RNStyleValue } from './types'
@@ -244,6 +245,8 @@ export function coerceUnparsedValue(text: string): RNStyleValue | null {
244
245
  if (px) return Number(px[1])
245
246
  const rem = LENGTH_REM_REGEX.exec(trimmed)
246
247
  if (rem) return Number(rem[1]) * REM_TO_PX
248
+ const colorMix = evaluateColorMixWithTransparent(trimmed)
249
+ if (colorMix !== null) return colorMix
247
250
  const fallback = extractVariableFallback(trimmed)
248
251
  if (fallback !== null) return coerceUnparsedValue(fallback)
249
252
  const calcRatio = CALC_RATIO_REGEX.exec(trimmed)
@@ -262,7 +265,174 @@ export function coerceUnparsedValue(text: string): RNStyleValue | null {
262
265
  const base = Number(calcMul[1]) * Number(calcMul[2])
263
266
  return unit === 'rem' ? base * REM_TO_PX : base
264
267
  }
265
- return trimmed
268
+ return unquoteCssString(trimmed)
269
+ }
270
+
271
+ /**
272
+ * Evaluate the specific `color-mix(in <space>, <color> <pct>%, transparent)`
273
+ * shape Tailwind v4 emits for opacity-suffixed themed colors (e.g.
274
+ * `border-text/20`, `bg-on-background/30`). The result is the original
275
+ * color with `alpha = originalAlpha * pct/100` — no actual color-space
276
+ * conversion is needed because mixing a color with `transparent` only
277
+ * changes its alpha, regardless of the named space (oklab / srgb /
278
+ * lab / …) — the chrominance is preserved.
279
+ *
280
+ * Returns null when the expression isn't this shape (handed back to
281
+ * the caller for the next coercion strategy).
282
+ * @param text Trimmed CSS value.
283
+ * @returns RN-compatible `rgba(...)` string, or null when unmatched.
284
+ */
285
+ function evaluateColorMixWithTransparent(text: string): string | null {
286
+ const lower = text.toLowerCase()
287
+ if (!lower.startsWith('color-mix(')) return null
288
+ // Match the trailing `, transparent)` (allowing optional whitespace).
289
+ const tail = /,\s*transparent\s*\)\s*$/.exec(text)
290
+ if (!tail) return null
291
+ // Skip the `in <space>` clause (everything up to the FIRST comma after
292
+ // the opening paren). Walking by hand instead of regex because the
293
+ // color slot may itself contain `(...)` (e.g. `rgb(...)`).
294
+ const inComma = text.indexOf(',', 'color-mix('.length)
295
+ if (inComma === -1 || inComma > tail.index) return null
296
+ const middle = text.slice(inComma + 1, tail.index).trim()
297
+ // `<color> <pct>%` — the `<num>%` token is at the END of `middle`.
298
+ // Anchored, no backtracking — explicit `% ` then end.
299
+ const pctMatch = COLOR_MIX_PCT_TAIL.exec(middle)
300
+ if (!pctMatch) return null
301
+ const pct = Number(pctMatch[1]) / 100
302
+ if (!Number.isFinite(pct)) return null
303
+ const colorText = middle.slice(0, pctMatch.index).trim()
304
+ if (colorText.length === 0) return null
305
+ return applyAlphaToCssColor(colorText, pct)
306
+ }
307
+
308
+ /** End-anchored `<num>%` matcher used to slice a color-mix percentage off the right of an expression. */
309
+ // eslint-disable-next-line sonarjs/slow-regex -- end-anchored, atomic-style group; bounded backtracking is safe.
310
+ const COLOR_MIX_PCT_TAIL = /(-?\d+(?:\.\d+)?)%$/
311
+
312
+ /**
313
+ * Multiply the alpha channel of a serialized CSS color by `multiplier`
314
+ * (0…1). Recognises `#rgb` / `#rrggbb` / `#rrggbbaa` hex, named colors
315
+ * (only `transparent` matters here), and `rgb(…)` / `rgba(…)` forms —
316
+ * which is what theme tokens resolve to after substitution.
317
+ * @param color CSS color text.
318
+ * @param multiplier Alpha multiplier (0…1).
319
+ * @returns `rgba(r, g, b, a)` string with the adjusted alpha.
320
+ */
321
+ function applyAlphaToCssColor(color: string, multiplier: number): string | null {
322
+ const trimmed = color.trim()
323
+ if (trimmed === 'transparent') return 'rgba(0, 0, 0, 0)'
324
+ return alphaFromHex(trimmed, multiplier) ?? alphaFromRgbFunction(trimmed, multiplier) ?? alphaFromCulori(trimmed, multiplier)
325
+ }
326
+
327
+ /**
328
+ * Apply the alpha multiplier to a hex literal, expanding 3/4/6/8-digit forms.
329
+ * @param text
330
+ * @param multiplier
331
+ */
332
+ function alphaFromHex(text: string, multiplier: number): string | null {
333
+ const hexMatch = /^#([0-9a-fA-F]{3,8})$/.exec(text)
334
+ if (!hexMatch) return null
335
+ const expanded = expandHex(hexMatch[1]!)
336
+ if (!expanded) return null
337
+ return `rgba(${expanded.r}, ${expanded.g}, ${expanded.b}, ${expanded.alpha * multiplier})`
338
+ }
339
+
340
+ /**
341
+ * Apply alpha to an `rgb(…)` / `rgba(…)` literal. Walks the channels by
342
+ * hand instead of a multi-capture regex (the linter flags the regex
343
+ * form as backtracking-prone).
344
+ * @param text
345
+ * @param multiplier
346
+ */
347
+ function alphaFromRgbFunction(text: string, multiplier: number): string | null {
348
+ if (!text.startsWith('rgb(') && !text.startsWith('rgba(')) return null
349
+ const inner = text.slice(text.indexOf('(') + 1, -1)
350
+ const channels = inner.split(/\s*[\s,]\s*/).filter((part) => part.length > 0)
351
+ if (channels.length !== 3 && channels.length !== 4) return null
352
+ const r = Math.round(Number(channels[0]))
353
+ const g = Math.round(Number(channels[1]))
354
+ const b = Math.round(Number(channels[2]))
355
+ const baseAlpha = channels.length === 4 ? Number(channels[3]) : 1
356
+ if (![r, g, b, baseAlpha].every((value) => Number.isFinite(value))) return null
357
+ return `rgba(${r}, ${g}, ${b}, ${baseAlpha * multiplier})`
358
+ }
359
+
360
+ /**
361
+ * Fallback for wide-gamut color forms (`oklch`, `oklab`, `lab`, `lch`,
362
+ * `hsl`, …) — culori parses every CSS color shape and yields RGB. Lets
363
+ * `color-mix(in oklab, oklch(...) 50%, transparent)` resolve when
364
+ * Tailwind emits the source color in a wide-gamut space (every
365
+ * built-in `bg-red-500` / `shadow-red-500` does, in v4).
366
+ * @param text
367
+ * @param multiplier
368
+ */
369
+ function alphaFromCulori(text: string, multiplier: number): string | null {
370
+ try {
371
+ const parsed = culoriRgb(text) as { r?: number; g?: number; b?: number; alpha?: number } | undefined
372
+ if (!parsed) return null
373
+ if (![parsed.r, parsed.g, parsed.b].every((v) => typeof v === 'number' && Number.isFinite(v))) return null
374
+ const r = Math.round(Math.max(0, Math.min(1, parsed.r!)) * 255)
375
+ const g = Math.round(Math.max(0, Math.min(1, parsed.g!)) * 255)
376
+ const b = Math.round(Math.max(0, Math.min(1, parsed.b!)) * 255)
377
+ const baseAlpha = typeof parsed.alpha === 'number' ? parsed.alpha : 1
378
+ return `rgba(${r}, ${g}, ${b}, ${baseAlpha * multiplier})`
379
+ } catch {
380
+ // culori threw on an unrecognised CSS form — fall through.
381
+ return null
382
+ }
383
+ }
384
+
385
+ /**
386
+ * Expand `#rgb` / `#rrggbb` / `#rrggbbaa` hex to its `{r, g, b, alpha}`
387
+ * components. Returns null when the digit count doesn't match a CSS hex
388
+ * shape.
389
+ * @param digits Hex digits without the leading `#`.
390
+ * @returns Decoded color or null.
391
+ */
392
+ function expandHex(digits: string): { r: number; g: number; b: number; alpha: number } | null {
393
+ const {length} = digits
394
+ if (length === 3 || length === 4) {
395
+ const r = Number.parseInt(digits[0]! + digits[0]!, 16)
396
+ const g = Number.parseInt(digits[1]! + digits[1]!, 16)
397
+ const b = Number.parseInt(digits[2]! + digits[2]!, 16)
398
+ const alpha = length === 4 ? Number.parseInt(digits[3]! + digits[3]!, 16) / 255 : 1
399
+ return Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b) ? null : { r, g, b, alpha }
400
+ }
401
+ if (length === 6 || length === 8) {
402
+ const r = Number.parseInt(digits.slice(0, 2), 16)
403
+ const g = Number.parseInt(digits.slice(2, 4), 16)
404
+ const b = Number.parseInt(digits.slice(4, 6), 16)
405
+ const alpha = length === 8 ? Number.parseInt(digits.slice(6, 8), 16) / 255 : 1
406
+ return Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b) ? null : { r, g, b, alpha }
407
+ }
408
+ return null
409
+ }
410
+
411
+ /**
412
+ * Strip the matched outer quote characters from a CSS string literal.
413
+ * `--font-sans: 'Inter-Medium'` flows through `var(--font-sans)`
414
+ * substitution as the raw value text — quotes included. Without this
415
+ * step `fontFamily` lands on the RN style as `"'Inter-Medium'"` (with
416
+ * literal quote characters), which RN can't match against the registered
417
+ * native font and silently falls back to the system face.
418
+ *
419
+ * Only strips when both ends agree (`'…'` or `"…"`) and there are no
420
+ * other top-level quote chars — keeps multi-segment fallbacks like
421
+ * `'Inter', sans-serif` untouched (those get split downstream).
422
+ * @param text Trimmed CSS value.
423
+ * @returns Same text with outer matching quotes removed, or unchanged.
424
+ */
425
+ function unquoteCssString(text: string): string {
426
+ if (text.length < 2) return text
427
+ const first = text.codePointAt(0)
428
+ const last = text.codePointAt(text.length - 1)
429
+ if (first === undefined || first !== last) return text
430
+ if (first !== 34 && first !== 39) return text // " or '
431
+ const inner = text.slice(1, -1)
432
+ // Don't unquote when the inner string itself contains an unescaped
433
+ // matching quote — that means we'd be merging two adjacent literals.
434
+ if (inner.includes(text[0]!)) return text
435
+ return inner
266
436
  }
267
437
 
268
438
  /**
@@ -8,7 +8,14 @@ import { detectHapticAtom, type HapticRequest } from './haptics'
8
8
  import { keyframeSelectorOffset, keyframesName, pickAnimationName } from './keyframes'
9
9
  import { serializeInitialValue } from './property'
10
10
  import { classNameFromSelector } from './selector'
11
- import { BASE_SCHEME, compileReadyTheme, extractSchemeAliases, extractThemeVars, type ThemeSchemeTable } from './theme-vars'
11
+ import {
12
+ BASE_SCHEME,
13
+ compileReadyTheme,
14
+ extractCustomVariantSchemes,
15
+ extractSchemeAliases,
16
+ extractThemeVars,
17
+ type ThemeSchemeTable,
18
+ } from './theme-vars'
12
19
  import { serializeTokens } from './tokens'
13
20
  import type { RNStyle } from './types'
14
21
  import type { Declaration as LcDeclaration, TokenOrValue } from 'lightningcss'
@@ -34,14 +41,18 @@ const DEFAULT_TRANSFORM_OPTIONS: Partial<TransformOptions<never>> = {
34
41
  },
35
42
  include: Features.Nesting | Features.MediaQueries,
36
43
  exclude: Features.LogicalProperties | Features.DirSelector | Features.LightDark,
37
- targets: {
38
- // eslint-disable-next-line sonarjs/no-identical-expressions
39
- safari: (16 << 16) | (4 << 8),
40
- // eslint-disable-next-line camelcase, sonarjs/no-identical-expressions
41
- ios_saf: (16 << 16) | (4 << 8),
42
- firefox: 128 << 16,
43
- chrome: 111 << 16,
44
- },
44
+ // NOTE: deliberately no `targets`. With targets that include
45
+ // color-mix-supporting browsers (Safari 16.4+, Chrome 111+, …),
46
+ // lightningcss EVALUATES `color-mix(in oklab, var(--theme-color)
47
+ // <pct>%, transparent)` at parse time using whichever value of
48
+ // `--theme-color` it sees first in the cascade. Tailwind v4 emits
49
+ // exactly this shape for `<prop>-<themed>/<N>` utilities (e.g.
50
+ // `border-text/20`), so the resulting RGB color is locked to ONE
51
+ // scheme — every variant gets the same value. By dropping targets,
52
+ // lightningcss leaves color-mix as an unparsed function and our
53
+ // per-scheme `unparsedToEntries` substitution path runs instead,
54
+ // producing the right rgba(...) for each scheme. Targets in this
55
+ // pipeline are otherwise unused — we never re-emit CSS from the AST.
45
56
  }
46
57
  /** Parser configuration — one instance per Metro session, theme CSS fixed. */
47
58
  export interface TailwindParserConfig {
@@ -164,6 +175,13 @@ export class TailwindParser {
164
175
  private compiler: TailwindCompiler | undefined
165
176
  private readonly themeSchemes: ThemeSchemeTable
166
177
  private readonly schemeAliases: ReadonlyMap<string, string>
178
+ /**
179
+ * Scheme names declared via `@custom-variant <name> …;`. A scheme
180
+ * listed here but absent from {@link themeSchemes} (no `@variant`
181
+ * override block) draws its values from the base `@theme` — the
182
+ * standard Tailwind v4 "light defaults + dark override" shape.
183
+ */
184
+ private readonly customVariantSchemes: readonly string[]
167
185
  /**
168
186
  * Memoise `resolveCandidates` results by candidate-list fingerprint.
169
187
  * Fast Refresh hits this on every save: oxide's scan is cheap, but
@@ -187,20 +205,41 @@ export class TailwindParser {
187
205
  constructor(private readonly config: TailwindParserConfig) {
188
206
  this.themeSchemes = extractThemeVars(config.themeCss)
189
207
  this.schemeAliases = extractSchemeAliases(config.themeCss)
208
+ this.customVariantSchemes = extractCustomVariantSchemes(config.themeCss)
190
209
  this.scanner = new Scanner({ sources: config.sources ? [...config.sources] : [] })
191
210
  }
192
211
 
193
212
  /**
194
- * Schemes declared by the user — either every `@variant <name>` block
195
- * found (in declaration order) or just `['base']` for themes without
196
- * any variants. Used to decide how many per-scheme buckets the
197
- * per-atom resolver fills. Exposed publicly so Metro integration can
198
- * hand the names to the `.d.ts` generator without a full parse.
213
+ * Schemes declared by the user — the union of every `@custom-variant
214
+ * <name>` declaration and every `@variant <name>` block, or just
215
+ * `['base']` for themes without any. Used to decide how many
216
+ * per-scheme buckets the per-atom resolver fills. Exposed publicly so
217
+ * Metro integration can hand the names to the `.d.ts` generator
218
+ * without a full parse.
219
+ *
220
+ * Both sources matter. `@variant` blocks alone miss the common
221
+ * Tailwind v4 shape where the light palette sits in the base `@theme`
222
+ * and only `@variant dark` overrides it: there `light` exists solely
223
+ * as a `@custom-variant` and would otherwise be dropped, collapsing
224
+ * every themed atom to a single bucket that can't switch.
225
+ * `@custom-variant` order wins (it's where users enumerate their
226
+ * schemes); any `@variant`-only scheme is appended after.
199
227
  * @returns Scheme names.
200
228
  */
201
229
  public get declaredSchemes(): readonly string[] {
202
- const variants = [...this.themeSchemes.keys()].filter((name) => name !== BASE_SCHEME)
203
- return variants.length > 0 ? variants : [BASE_SCHEME]
230
+ const ordered: string[] = []
231
+ const seen = new Set<string>()
232
+ for (const name of this.customVariantSchemes) {
233
+ if (seen.has(name)) continue
234
+ seen.add(name)
235
+ ordered.push(name)
236
+ }
237
+ for (const name of this.themeSchemes.keys()) {
238
+ if (name === BASE_SCHEME || seen.has(name)) continue
239
+ seen.add(name)
240
+ ordered.push(name)
241
+ }
242
+ return ordered.length > 0 ? ordered : [BASE_SCHEME]
204
243
  }
205
244
 
206
245
  /**
@@ -291,6 +330,19 @@ export class TailwindParser {
291
330
  } catch (error) {
292
331
  throw wrapThemeError(error)
293
332
  }
333
+ // Tailwind v4 emits opacity-suffixed themed colors as a pre-resolved
334
+ // sRGB fallback PLUS a `@supports`-gated var()-based override:
335
+ // border-color: color-mix(in srgb, #0A0A0A 20%, transparent);
336
+ // @supports (color: color-mix(in lab, red, red)) {
337
+ // border-color: color-mix(in oklab, var(--color-text) 20%, transparent);
338
+ // }
339
+ // Lightningcss takes the OUTER fallback (locked to whichever scheme
340
+ // the compiler resolved first), and our per-scheme substitution
341
+ // never gets a chance. Unwrap the @supports so the var()-based
342
+ // declaration overrides the fallback in the same rule — lightningcss
343
+ // emits the override as `unparsed` and the parser's themeVars-aware
344
+ // path produces correct rgba per scheme.
345
+ css = unwrapColorMixSupports(css)
294
346
  // `compiler.build(candidates)` memoizes across calls — it returns CSS for
295
347
  // every candidate the compiler has EVER seen in this process. To keep
296
348
  // parser output pure per-call we restrict outputs to this call's
@@ -1068,20 +1120,24 @@ function parseFirstShadow(raw: string): ParsedShadow | null {
1068
1120
  * @returns Pixel lengths + the remainder text (color expression).
1069
1121
  */
1070
1122
  function extractShadowLengths(head: string): { lengths: number[]; remainder: string } {
1071
- const lengthRegex = /(?<![A-Za-z(])-?\d*\.?\d+(?:px|rem|em|%)?/g
1123
+ // Take ONLY the leading run of length tokens, stopping at the first
1124
+ // non-length token (the color). A previous global digit-regex scanned
1125
+ // the whole string, so a <4-length shadow like `0 1px 1px rgb(0 0 0 /
1126
+ // 0.05)` stole a digit out of the color expression — corrupting the
1127
+ // alpha (opacity) or a digit-leading hex. Whitespace-splitting can't
1128
+ // reach inside the color because we break as soon as a token isn't a
1129
+ // bare/`px`/`rem`/`em`/`%` length.
1130
+ // Unambiguous integer-or-decimal (no `\d*\.?\d+` overlap) so there's no
1131
+ // super-linear backtracking on long digit runs.
1132
+ const isLength = /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em|%)?$/
1133
+ const parts = head.split(/\s+/)
1072
1134
  const lengths: number[] = []
1073
- const matches: { text: string; index: number }[] = []
1074
- let next: RegExpExecArray | null = lengthRegex.exec(head)
1075
- while (next !== null && lengths.length < 4) {
1076
- matches.push({ text: next[0], index: next.index })
1077
- lengths.push(parseLengthToken(next[0]))
1078
- next = lengthRegex.exec(head)
1079
- }
1080
- let remainder = head
1081
- for (const { text, index } of matches.toReversed()) {
1082
- remainder = `${remainder.slice(0, index)}${remainder.slice(index + text.length)}`
1135
+ let index = 0
1136
+ while (index < parts.length && lengths.length < 4 && isLength.test(parts[index]!)) {
1137
+ lengths.push(parseLengthToken(parts[index]!))
1138
+ index += 1
1083
1139
  }
1084
- return { lengths, remainder }
1140
+ return { lengths, remainder: parts.slice(index).join(' ') }
1085
1141
  }
1086
1142
 
1087
1143
  /**
@@ -1618,6 +1674,69 @@ function resolveAngleExpression(text: string): string | null {
1618
1674
  * @param css Tailwind's compiled CSS.
1619
1675
  * @returns Map of custom-property name → resolved value.
1620
1676
  */
1677
+ /**
1678
+ * Strip `\@supports (color: color-mix(in lab, red, red)) { … }` wrappers
1679
+ * from Tailwind v4's compiled CSS, hoisting their inner declarations up
1680
+ * to the parent rule.
1681
+ *
1682
+ * Tailwind emits opacity-suffixed themed colors with both a pre-resolved
1683
+ * sRGB fallback AND a var()-based override gated behind the color-mix
1684
+ * `\@supports` clause. The OUTER fallback hard-codes a single scheme's
1685
+ * value of the theme token; the inner override is var()-based and
1686
+ * substitutes correctly per scheme. By unwrapping the gate, the inner
1687
+ * declaration becomes a sibling of the fallback in the same rule body —
1688
+ * lightningcss takes the LATER one (the var()-based unparsed form), and
1689
+ * the parser's themeVars-aware path produces correct rgba per scheme.
1690
+ * Modern RN-targeted browsers all support color-mix anyway, so dropping
1691
+ * the gating is safe.
1692
+ * @param css Tailwind-compiled CSS.
1693
+ * @returns CSS with the color-mix support gates unwrapped.
1694
+ */
1695
+ function unwrapColorMixSupports(css: string): string {
1696
+ const guard = '@supports (color: color-mix(in lab, red, red))'
1697
+ let out = ''
1698
+ let cursor = 0
1699
+ while (cursor < css.length) {
1700
+ const head = css.indexOf(guard, cursor)
1701
+ if (head === -1) {
1702
+ out += css.slice(cursor)
1703
+ break
1704
+ }
1705
+ out += css.slice(cursor, head)
1706
+ const brace = css.indexOf('{', head)
1707
+ if (brace === -1) {
1708
+ out += css.slice(head)
1709
+ break
1710
+ }
1711
+ const blockEnd = findMatchingClose(css, brace + 1)
1712
+ if (blockEnd === -1) {
1713
+ out += css.slice(head)
1714
+ break
1715
+ }
1716
+ const inner = css.slice(brace + 1, blockEnd)
1717
+ // Only unwrap when the gated declaration substitutes a USER theme
1718
+ // token (`var(--color-…)`). Tailwind also gates `--tw-*` internal
1719
+ // composers (shadow color, ring color, …) on the same supports
1720
+ // clause; their outer fallback is the optimized hex/oklch value
1721
+ // the parser's own composed-prop pass needs (`applyComposedShadow`
1722
+ // reads `--tw-shadow-color` from the rule's local vars). Unwrapping
1723
+ // them would replace the resolvable color with an unresolvable
1724
+ // `color-mix(... var(--tw-shadow-alpha), transparent)` text and
1725
+ // break the composed-shadow path.
1726
+ // Keep the gate intact for non-themed colors — the outer fallback
1727
+ // wins, which is what Tailwind intended.
1728
+ out += inner.includes('var(--color-') ? inner : css.slice(head, blockEnd + 1)
1729
+ cursor = blockEnd + 1
1730
+ }
1731
+ return out
1732
+ }
1733
+
1734
+ /**
1735
+ * Extract every `--name: value` declaration from the `:root` blocks in
1736
+ * Tailwind's compiled CSS into a flat map.
1737
+ * @param css Tailwind-compiled CSS.
1738
+ * @returns Map of custom-property name → resolved value.
1739
+ */
1621
1740
  function extractRootCustomProperties(css: string): Map<string, string> {
1622
1741
  const out = new Map<string, string>()
1623
1742
  let cursor = 0
@@ -48,6 +48,20 @@ function letterSpacingToEntries(value: LcDeclaration['value']): readonly RNEntry
48
48
  return [['letterSpacing', unit === 'px' ? px : px * 16]]
49
49
  }
50
50
 
51
+ /**
52
+ * Lower a CSS `text-align` keyword to one RN's `textAlign` accepts. RN
53
+ * has no logical `start`/`end`, so map them to physical sides (LTR
54
+ * default); every other keyword (left/right/center/justify/auto) is
55
+ * already valid and passes through.
56
+ * @param align CSS text-align keyword.
57
+ * @returns RN-valid textAlign keyword.
58
+ */
59
+ function physicalTextAlign(align: string): string {
60
+ if (align === 'start') return 'left'
61
+ if (align === 'end') return 'right'
62
+ return align
63
+ }
64
+
51
65
  /**
52
66
  * Dispatch typography declarations rnwind cares about (text-align,
53
67
  * text-transform, text-decoration-line, line-height, letter-spacing,
@@ -59,7 +73,7 @@ function letterSpacingToEntries(value: LcDeclaration['value']): readonly RNEntry
59
73
  export function dispatchTypographyDeclaration(decl: LcDeclaration): readonly RNEntry[] | null {
60
74
  switch (decl.property) {
61
75
  case 'text-align': {
62
- return [['textAlign', decl.value]]
76
+ return [['textAlign', physicalTextAlign(String(decl.value))]]
63
77
  }
64
78
  case 'text-transform': {
65
79
  return [['textTransform', decl.value.case ?? 'none']]
@@ -309,25 +309,84 @@ export interface AtomSerializedEntry {
309
309
  export type AtomSerializedCache = Map<string, AtomSerializedEntry>
310
310
 
311
311
  /**
312
- * Serialize one atom's canonical + variant-diff entries, honouring
313
- * the per-atom cache. Returns the number of cache MISSES this atom
314
- * incurred (0 when canonical was cached AND every needed variant was
315
- * cached; 1 when anything had to be re-stringified). Split out of
316
- * `buildSchemeSources` to keep that function under the repo's cognitive
317
- * complexity budget.
312
+ * Pre-serialize every non-empty variant value, reusing the per-atom
313
+ * cache where present. Result drives both the scheme-uniform check
314
+ * AND the per-variant emission loop downstream.
315
+ * @param atom Atom name.
316
+ * @param schemed Parser-produced schemed bucket.
317
+ * @param variants Variant scheme names in deterministic order.
318
+ * @param keyframes Keyframes available to inline.
319
+ * @param cached Cached entry for this atom (when ref-stable).
320
+ * @returns variantName → serialized text.
321
+ */
322
+ function buildVariantTexts(
323
+ atom: string,
324
+ schemed: SchemedStyle,
325
+ variants: readonly string[],
326
+ keyframes: ReadonlyMap<string, KeyframeBlock>,
327
+ cached: AtomSerializedEntry | undefined,
328
+ ): Map<string, string> {
329
+ const out = new Map<string, string>()
330
+ for (const variant of variants) {
331
+ const own = (schemed as Readonly<Record<string, RNStyle>>)[variant]
332
+ if (!isNonEmptyStyle(own)) continue
333
+ const text = cached?.variants.get(variant) ?? prepareAtomValue(atom, own, keyframes)
334
+ out.set(variant, text)
335
+ }
336
+ return out
337
+ }
338
+
339
+ /**
340
+ * Decide whether a (no-base) atom should be promoted to common because
341
+ * every declared variant resolves to the same value. This is the
342
+ * scheme-uniform case: `flex`, `p-4`, `absolute` all carry no theme
343
+ * variables, so Phase-1 fills every variant bucket identically and
344
+ * leaves `base` empty — without this collapse they'd be duplicated
345
+ * across every scheme file.
346
+ *
347
+ * The variant-prefix check is what keeps a real scheme-gated atom
348
+ * (`dark:bg-indigo-800`) out of common in a single-variant project
349
+ * (where its 1 bucket would otherwise look "uniform" by definition).
350
+ * @param atom Atom name (checked for `<variant>:` prefix).
351
+ * @param variants Declared variant scheme names.
352
+ * @param variantTexts Serialized variant values.
353
+ * @param canonicalText Serialized canonical (common) value.
354
+ * @returns Whether the atom is uniform across every declared variant.
355
+ */
356
+ function isSchemeUniform(
357
+ atom: string,
358
+ variants: readonly string[],
359
+ variantTexts: ReadonlyMap<string, string>,
360
+ canonicalText: string,
361
+ ): boolean {
362
+ if (variants.length === 0 || variantTexts.size !== variants.length) return false
363
+ if (variants.some((variant) => atom.startsWith(`${variant}:`))) return false
364
+ for (const text of variantTexts.values()) {
365
+ if (text !== canonicalText) return false
366
+ }
367
+ return true
368
+ }
369
+
370
+ /**
371
+ * Serialize one atom's canonical + variant-diff entries, honouring the
372
+ * per-atom cache. Returns the number of cache MISSES this atom incurred
373
+ * (0 when canonical was cached AND every needed variant was cached;
374
+ * 1 when anything had to be re-stringified).
318
375
  *
319
- * Two paths gated on whether the parser produced a non-empty `base`
320
- * bucket:
376
+ * Three paths gated on whether the parser produced a non-empty `base`
377
+ * bucket and whether the variants converge:
321
378
  * - **Themed atom (base present)**: canonical goes to `common`, each
322
379
  * variant whose own value diverges from canonical writes the diff
323
- * into its own scheme file. `lookupAtom` then finds the variant's
380
+ * into its own scheme file. `lookupAtom` finds the variant's
324
381
  * override or falls through to common.
325
- * - **Scheme-gated atom (base empty)**: the atom only matches when a
326
- * specific scheme is active (Tailwind `light:` / `dark:` / `brand:`
327
- * selectors). Skipping `common` here is essential registering it
328
- * would let the lookup fall through under non-matching schemes and
329
- * leak the variant style into every scheme. Each populated variant
330
- * bucket writes the value into its own scheme file directly.
382
+ * - **Scheme-uniform atom (base empty, every variant identical)**:
383
+ * promoted to `common` once the parser's Phase-1 fills every
384
+ * variant bucket with the same value for utilities like `flex` /
385
+ * `p-4` / `absolute` that don't reference theme variables.
386
+ * - **Scheme-gated atom (base empty, prefixed name like `dark:foo`,
387
+ * or variants diverge)**: each populated variant writes the value
388
+ * into its own scheme file directly; common stays empty so the
389
+ * runtime fallback can't leak the variant style into other schemes.
331
390
  * @param atom Atom name.
332
391
  * @param schemed Parser-produced schemed bucket for the atom.
333
392
  * @param canonical Canonical RN style for `common`.
@@ -351,23 +410,22 @@ function collectAtomEntries(
351
410
  const cached = cache?.get(atom)
352
411
  const hit = cached?.styleRef === schemed
353
412
  const baseEntry = (schemed as Readonly<Record<string, RNStyle>>)[BASE_SCHEME]
354
- const isSchemeGated = !isNonEmptyStyle(baseEntry)
413
+ const hasBase = isNonEmptyStyle(baseEntry)
355
414
  const canonicalText = hit ? cached.canonical : prepareAtomValue(atom, canonical, keyframes)
356
- if (!isSchemeGated) commonEntries.push([atom, canonicalText])
415
+ const variantTexts = buildVariantTexts(atom, schemed, variants, keyframes, hit ? cached : undefined)
416
+ const goesToCommon = hasBase || isSchemeUniform(atom, variants, variantTexts, canonicalText)
417
+
418
+ if (goesToCommon) commonEntries.push([atom, canonicalText])
419
+
357
420
  const entry: AtomSerializedEntry = hit
358
421
  ? cached
359
- : { styleRef: schemed, canonical: canonicalText, variants: new Map<string, string>() }
422
+ : { styleRef: schemed, canonical: canonicalText, variants: new Map(variantTexts) }
360
423
  if (!hit) cache?.set(atom, entry)
361
424
 
362
425
  for (const variant of variants) {
363
- const own = (schemed as Readonly<Record<string, RNStyle>>)[variant]
364
- if (!isNonEmptyStyle(own)) continue
365
- let ownText = entry.variants.get(variant)
366
- if (ownText === undefined) {
367
- ownText = prepareAtomValue(atom, own, keyframes)
368
- entry.variants.set(variant, ownText)
369
- }
370
- if (!isSchemeGated && ownText === canonicalText) continue
426
+ const ownText = variantTexts.get(variant)
427
+ if (ownText === undefined) continue
428
+ if (goesToCommon && ownText === canonicalText) continue
371
429
  variantEntries[variant].push([atom, ownText])
372
430
  }
373
431
  return hit ? 0 : 1