rnwind 0.0.7 → 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 +161 -10
  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 +43 -60
  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 +162 -11
  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 +43 -60
  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 +160 -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 +45 -61
  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
@@ -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
 
@@ -321,28 +321,47 @@ const COLOR_MIX_PCT_TAIL = /(-?\d+(?:\.\d+)?)%$/
321
321
  function applyAlphaToCssColor(color: string, multiplier: number): string | null {
322
322
  const trimmed = color.trim()
323
323
  if (trimmed === 'transparent') return 'rgba(0, 0, 0, 0)'
324
+ // Nested color-mix — Tailwind's `shadow-<token>/<opacity>` emits
325
+ // `color-mix(… color-mix(… <token> N%, transparent) <alpha>, transparent)`.
326
+ // Resolve the inner mix to a concrete color first, then apply this alpha.
327
+ if (trimmed.toLowerCase().startsWith('color-mix(')) {
328
+ const inner = evaluateColorMixWithTransparent(trimmed)
329
+ if (inner !== null) return applyAlphaToCssColor(inner, multiplier)
330
+ }
324
331
  return alphaFromHex(trimmed, multiplier) ?? alphaFromRgbFunction(trimmed, multiplier) ?? alphaFromCulori(trimmed, multiplier)
325
332
  }
326
333
 
334
+ /**
335
+ * Round a composed alpha to 4 decimals — `0.2 * 1` round-trips through f32 as
336
+ * `0.20000000298…`; the rounded form keeps generated rgba strings compact.
337
+ * @param alpha Raw alpha product.
338
+ * @returns Rounded alpha.
339
+ */
340
+ function roundAlpha(alpha: number): number {
341
+ return Math.round(alpha * 10_000) / 10_000
342
+ }
343
+
327
344
  /**
328
345
  * Apply the alpha multiplier to a hex literal, expanding 3/4/6/8-digit forms.
329
- * @param text
330
- * @param multiplier
346
+ * @param text Candidate hex color string.
347
+ * @param multiplier Alpha multiplier (0…1).
348
+ * @returns `rgba(…)` string, or null when `text` is not a hex literal.
331
349
  */
332
350
  function alphaFromHex(text: string, multiplier: number): string | null {
333
351
  const hexMatch = /^#([0-9a-fA-F]{3,8})$/.exec(text)
334
352
  if (!hexMatch) return null
335
353
  const expanded = expandHex(hexMatch[1]!)
336
354
  if (!expanded) return null
337
- return `rgba(${expanded.r}, ${expanded.g}, ${expanded.b}, ${expanded.alpha * multiplier})`
355
+ return `rgba(${expanded.r}, ${expanded.g}, ${expanded.b}, ${roundAlpha(expanded.alpha * multiplier)})`
338
356
  }
339
357
 
340
358
  /**
341
359
  * Apply alpha to an `rgb(…)` / `rgba(…)` literal. Walks the channels by
342
360
  * hand instead of a multi-capture regex (the linter flags the regex
343
361
  * form as backtracking-prone).
344
- * @param text
345
- * @param multiplier
362
+ * @param text Candidate `rgb(…)` / `rgba(…)` color string.
363
+ * @param multiplier Alpha multiplier (0…1).
364
+ * @returns `rgba(…)` string, or null when `text` is not an rgb function.
346
365
  */
347
366
  function alphaFromRgbFunction(text: string, multiplier: number): string | null {
348
367
  if (!text.startsWith('rgb(') && !text.startsWith('rgba(')) return null
@@ -354,7 +373,7 @@ function alphaFromRgbFunction(text: string, multiplier: number): string | null {
354
373
  const b = Math.round(Number(channels[2]))
355
374
  const baseAlpha = channels.length === 4 ? Number(channels[3]) : 1
356
375
  if (![r, g, b, baseAlpha].every((value) => Number.isFinite(value))) return null
357
- return `rgba(${r}, ${g}, ${b}, ${baseAlpha * multiplier})`
376
+ return `rgba(${r}, ${g}, ${b}, ${roundAlpha(baseAlpha * multiplier)})`
358
377
  }
359
378
 
360
379
  /**
@@ -363,8 +382,9 @@ function alphaFromRgbFunction(text: string, multiplier: number): string | null {
363
382
  * `color-mix(in oklab, oklch(...) 50%, transparent)` resolve when
364
383
  * Tailwind emits the source color in a wide-gamut space (every
365
384
  * built-in `bg-red-500` / `shadow-red-500` does, in v4).
366
- * @param text
367
- * @param multiplier
385
+ * @param text Candidate wide-gamut / named CSS color string.
386
+ * @param multiplier Alpha multiplier (0…1).
387
+ * @returns `rgba(…)` string, or null when culori can't parse `text`.
368
388
  */
369
389
  function alphaFromCulori(text: string, multiplier: number): string | null {
370
390
  try {
@@ -375,7 +395,7 @@ function alphaFromCulori(text: string, multiplier: number): string | null {
375
395
  const g = Math.round(Math.max(0, Math.min(1, parsed.g!)) * 255)
376
396
  const b = Math.round(Math.max(0, Math.min(1, parsed.b!)) * 255)
377
397
  const baseAlpha = typeof parsed.alpha === 'number' ? parsed.alpha : 1
378
- return `rgba(${r}, ${g}, ${b}, ${baseAlpha * multiplier})`
398
+ return `rgba(${r}, ${g}, ${b}, ${roundAlpha(baseAlpha * multiplier)})`
379
399
  } catch {
380
400
  // culori threw on an unrecognised CSS form — fall through.
381
401
  return null
@@ -450,6 +470,53 @@ export function coerceFontFamily(value: string): string {
450
470
  return unquoteCssString(first)
451
471
  }
452
472
 
473
+ /**
474
+ * Generic CSS font-family keywords — NOT real React Native typefaces. A
475
+ * `font-family` stack made only of these (e.g. the default `font-sans`:
476
+ * `ui-sans-serif, system-ui, sans-serif`) should fall back to RN's system
477
+ * font rather than emit a bogus `fontFamily`.
478
+ */
479
+ const GENERIC_FONT_FAMILIES: ReadonlySet<string> = new Set([
480
+ 'ui-sans-serif',
481
+ 'ui-serif',
482
+ 'ui-monospace',
483
+ 'ui-rounded',
484
+ 'system-ui',
485
+ 'sans-serif',
486
+ 'serif',
487
+ 'monospace',
488
+ 'cursive',
489
+ 'fantasy',
490
+ 'math',
491
+ 'emoji',
492
+ 'fangsong',
493
+ '-apple-system',
494
+ 'blinkmacsystemfont',
495
+ // Emoji / symbol fonts that Tailwind appends to the default sans stack —
496
+ // never the intended text typeface.
497
+ 'apple color emoji',
498
+ 'segoe ui emoji',
499
+ 'segoe ui symbol',
500
+ 'noto color emoji',
501
+ ])
502
+
503
+ /**
504
+ * Pick the first CONCRETE typeface from a typed `font-family` LIST (a CSS
505
+ * fallback stack). RN takes one family, and generic keywords aren't real
506
+ * faces, so skip them. Returns undefined when the whole stack is generic
507
+ * (→ caller emits nothing → system font).
508
+ * @param families Typed `font-family` value — an array of family-name strings.
509
+ * @returns First concrete family name, or undefined.
510
+ */
511
+ export function firstConcreteFontFamily(families: readonly unknown[]): string | undefined {
512
+ for (const entry of families) {
513
+ if (typeof entry !== 'string') continue
514
+ const bare = coerceFontFamily(entry)
515
+ if (bare.length > 0 && !GENERIC_FONT_FAMILIES.has(bare.toLowerCase())) return bare
516
+ }
517
+ return undefined
518
+ }
519
+
453
520
  /**
454
521
  * Substitute every `var(--name [, fallback])` reference in `text` with
455
522
  * the value from `table` (or the fallback clause when the name misses).
@@ -103,37 +103,48 @@ function angleToString(angle: Angle): string {
103
103
 
104
104
  /**
105
105
  * Convert a `NumberOrPercentage` to a plain number. Percentages become
106
- * their fractional equivalent (e.g. `50%` → `0.5`).
106
+ * their fractional equivalent (e.g. `50%` → `0.5`). Rounded so a literal
107
+ * like `scale-[1.7]` doesn't carry lightningcss's f32 noise
108
+ * (`1.7000000476837158`) into the RN `transform` array.
107
109
  * @param value Typed value.
108
110
  * @returns Plain number.
109
111
  */
110
112
  function numberOrPercentageToNumber(value: NumberOrPercentage): number {
111
- if (value.type === 'percentage') return value.value
112
- return value.value
113
+ return roundNumber(value.value)
113
114
  }
114
115
 
115
116
  /**
116
117
  * Convert a length-or-percentage used by translate into the shape RN
117
118
  * accepts (`number` for px, `string` for `%`). Percentages stay as
118
- * strings so RN layout can resolve them against the element size.
119
+ * strings so RN layout can resolve them against the element size. Pixel
120
+ * values are rounded to shed f32 noise (`3.3px` → `3.299999952…`).
119
121
  * @param value Typed length or percentage.
120
122
  * @returns RN-style translate value.
121
123
  */
122
124
  function lengthOrPercentToNumber(value: DimensionPercent | { type: 'value'; value: LengthValue }): number | string {
123
- if (value.type === 'dimension') return lengthToPx(value.value)
124
- if (value.type === 'value') return lengthToPx(value.value)
125
+ if (value.type === 'dimension') return roundNumber(lengthToPx(value.value))
126
+ if (value.type === 'value') return roundNumber(lengthToPx(value.value))
125
127
  if (value.type === 'percentage') return `${formatNumber(value.value * 100)}%`
126
128
  return 0
127
129
  }
128
130
 
131
+ /**
132
+ * Round a number to 4 decimals — sheds lightningcss's f32 representation
133
+ * noise while staying well below subpixel / sub-percent precision.
134
+ * @param value Raw number.
135
+ * @returns Rounded number.
136
+ */
137
+ function roundNumber(value: number): number {
138
+ return Math.round(value * 10_000) / 10_000
139
+ }
140
+
129
141
  /**
130
142
  * Render a number without trailing IEEE noise.
131
143
  * @param value Number to format.
132
144
  * @returns Compact string form.
133
145
  */
134
146
  function formatNumber(value: number): string {
135
- const rounded = Math.round(value * 10_000) / 10_000
136
- return String(rounded)
147
+ return String(roundNumber(value))
137
148
  }
138
149
 
139
150
  /**
@@ -5,7 +5,7 @@ import { Features, transform, type TransformOptions } from 'lightningcss'
5
5
  import { declarationToRnEntries } from './declaration'
6
6
  import { detectGradientAtom, type GradientAtomInfo } from './gradient'
7
7
  import { detectHapticAtom, type HapticRequest } from './haptics'
8
- import { keyframeSelectorOffset, keyframesName, pickAnimationName } from './keyframes'
8
+ import { keyframeSelectorOffsets, keyframesName, pickAnimationName } from './keyframes'
9
9
  import { serializeInitialValue } from './property'
10
10
  import { classNameFromSelector } from './selector'
11
11
  import {
@@ -16,7 +16,8 @@ import {
16
16
  extractThemeVars,
17
17
  type ThemeSchemeTable,
18
18
  } from './theme-vars'
19
- import { serializeTokens } from './tokens'
19
+ import { coerceUnparsedValue, serializeTokens, substituteThemeVars } from './tokens'
20
+ import { normalizeColorString } from './color'
20
21
  import type { RNStyle } from './types'
21
22
  import type { Declaration as LcDeclaration, TokenOrValue } from 'lightningcss'
22
23
 
@@ -398,7 +399,8 @@ export class TailwindParser {
398
399
  // surface their role + resolved colour so the transformer
399
400
  // can rewrite `<LinearGradient className="...">` into
400
401
  // `colors={...}` / `start={...}` / `end={...}` props.
401
- const gradient = detectGradientAtom(rule.value.declarations.declarations)
402
+ const gradientTable = schemeTables.get(BASE_SCHEME) ?? schemeTables.get(schemes[0] ?? BASE_SCHEME)
403
+ const gradient = detectGradientAtom(rule.value.declarations.declarations, gradientTable)
402
404
  if (gradient) gradientAtoms.set(className, gradient)
403
405
  // Haptics may live on the rule directly OR inside a
404
406
  // nested pseudo (e.g. `&:active` for `active:haptic-*`).
@@ -415,14 +417,16 @@ export class TailwindParser {
415
417
  const steps: KeyframeStep[] = []
416
418
  const baseTable = schemeTables.get(BASE_SCHEME) ?? schemeTables.get(schemes[0] ?? BASE_SCHEME)
417
419
  for (const frame of rule.value.keyframes) {
418
- const offset = keyframeSelectorOffset(frame.selectors)
419
- if (!offset) continue
420
+ const offsets = keyframeSelectorOffsets(frame.selectors)
421
+ if (offsets.length === 0) continue
420
422
  const style: RNStyle = {}
421
423
  const frameDecls = frame.declarations.declarations ?? []
422
424
  for (const decl of frameDecls) {
423
425
  for (const [key, value] of declarationToRnEntries(decl, baseTable)) style[key] = value
424
426
  }
425
- steps.push({ offset, style })
427
+ // One frame can carry several offsets (`0%, 100% { }`); emit a
428
+ // step for each so the terminal frame isn't lost.
429
+ for (const offset of offsets) steps.push({ offset, style })
426
430
  }
427
431
  keyframes.set(name, { name, steps })
428
432
  },
@@ -582,8 +586,8 @@ function processStyleRule(
582
586
  if (animationRef) ctx.referencedKeyframes.add(animationRef)
583
587
  }
584
588
  applyComposedTransform(bucket, ctx.schemes, ruleLocalVars)
585
- applyComposedShadow(bucket, ctx.schemes, ruleLocalVars)
586
- applyComposedRing(bucket, ctx.schemes, ruleLocalVars)
589
+ applyComposedShadow(bucket, ctx.schemes, ruleLocalVars, ruleSchemeTables)
590
+ applyComposedRing(bucket, ctx.schemes, ruleLocalVars, ruleSchemeTables)
587
591
  // Phase 2: nested rules — three orthogonal flavours, dispatched on
588
592
  // the lightningcss node `type`:
589
593
  // - `media`: Tailwind v4 responsive variants (`sm:`, `md:`, …) wrap
@@ -729,7 +733,7 @@ function applyMediaRule(
729
733
  const nestedLocalVars = new Map(ruleLocalVars)
730
734
  for (const [k, v] of collectRuleLocalVars(decls)) nestedLocalVars.set(k, v)
731
735
  applyComposedTransformToScheme(schemeBucket, nestedLocalVars)
732
- applyComposedShadowToScheme(schemeBucket, nestedLocalVars)
736
+ applyComposedShadowToScheme(schemeBucket, nestedLocalVars, table)
733
737
  bucket[scheme] = schemeBucket
734
738
  }
735
739
  }
@@ -769,7 +773,7 @@ function applyInteractiveNestedRule(
769
773
  const nestedLocalVars = new Map(ruleLocalVars)
770
774
  for (const [k, v] of collectRuleLocalVars(decls)) nestedLocalVars.set(k, v)
771
775
  applyComposedTransformToScheme(schemeBucket, nestedLocalVars)
772
- applyComposedShadowToScheme(schemeBucket, nestedLocalVars)
776
+ applyComposedShadowToScheme(schemeBucket, nestedLocalVars, table)
773
777
  bucket[scheme] = schemeBucket
774
778
  }
775
779
  }
@@ -832,7 +836,7 @@ function applyNestedSchemeRule(
832
836
  const nestedLocalVars = new Map(ruleLocalVars)
833
837
  for (const [k, v] of collectRuleLocalVars(innerDecls)) nestedLocalVars.set(k, v)
834
838
  applyComposedTransformToScheme(schemeBucket, nestedLocalVars)
835
- applyComposedShadowToScheme(schemeBucket, nestedLocalVars)
839
+ applyComposedShadowToScheme(schemeBucket, nestedLocalVars, table)
836
840
  bucket[targetScheme] = schemeBucket
837
841
  }
838
842
 
@@ -948,12 +952,17 @@ function applyComposedTransformToScheme(style: RNStyle, ruleLocalVars: ReadonlyM
948
952
  * prop.
949
953
  * @param style Scheme-specific style map.
950
954
  * @param ruleLocalVars Combined outer+nested `--tw-*` vars.
955
+ * @param table Per-scheme var table for resolving `var(--color-x)` in colors.
951
956
  */
952
- function applyComposedShadowToScheme(style: RNStyle, ruleLocalVars: ReadonlyMap<string, string>): void {
957
+ function applyComposedShadowToScheme(
958
+ style: RNStyle,
959
+ ruleLocalVars: ReadonlyMap<string, string>,
960
+ table?: ReadonlyMap<string, string>,
961
+ ): void {
953
962
  const rawShadow = ruleLocalVars.get('--tw-shadow')
954
963
  const rawShadowColor = ruleLocalVars.get('--tw-shadow-color')
955
964
  if (!rawShadow && rawShadowColor) {
956
- const color = resolveCustomColorString(rawShadowColor)
965
+ const color = resolveCustomColorString(rawShadowColor, table)
957
966
  if (!color) return
958
967
  delete style.boxShadow
959
968
  style.shadowColor = color
@@ -980,11 +989,13 @@ function applyComposedShadowToScheme(style: RNStyle, ruleLocalVars: ReadonlyMap<
980
989
  * @param bucket Per-scheme style map for the atom.
981
990
  * @param schemes Scheme names active for this parse.
982
991
  * @param ruleLocalVars Rule-local `--tw-*` vars.
992
+ * @param schemeTables Per-scheme var tables for resolving `var(--color-x)`.
983
993
  */
984
994
  function applyComposedShadow(
985
995
  bucket: Record<string, RNStyle>,
986
996
  schemes: readonly string[],
987
997
  ruleLocalVars: ReadonlyMap<string, string>,
998
+ schemeTables: ReadonlyMap<string, ReadonlyMap<string, string>>,
988
999
  ): void {
989
1000
  const rawShadow = ruleLocalVars.get('--tw-shadow')
990
1001
  const rawShadowColor = ruleLocalVars.get('--tw-shadow-color')
@@ -994,9 +1005,10 @@ function applyComposedShadow(
994
1005
  // where setting `--tw-shadow-color` swaps in a solid color). Offset /
995
1006
  // blur / elevation come from the partner size utility's atom.
996
1007
  if (!rawShadow && rawShadowColor) {
997
- const color = resolveCustomColorString(rawShadowColor)
998
- if (!color) return
999
1008
  for (const scheme of schemes) {
1009
+ // Resolve per scheme — a custom token may differ between light/dark.
1010
+ const color = resolveCustomColorString(rawShadowColor, schemeTables.get(scheme))
1011
+ if (!color) continue
1000
1012
  const style = bucket[scheme] ?? {}
1001
1013
  delete style.boxShadow
1002
1014
  style.shadowColor = color
@@ -1028,35 +1040,64 @@ function applyComposedShadow(
1028
1040
  * @param bucket Per-scheme style map for the atom.
1029
1041
  * @param schemes Scheme names active for this parse.
1030
1042
  * @param ruleLocalVars Rule-local `--tw-*` vars.
1043
+ * @param schemeTables Per-scheme var tables for resolving `var(--color-x)`.
1031
1044
  */
1032
1045
  function applyComposedRing(
1033
1046
  bucket: Record<string, RNStyle>,
1034
1047
  schemes: readonly string[],
1035
1048
  ruleLocalVars: ReadonlyMap<string, string>,
1049
+ schemeTables: ReadonlyMap<string, ReadonlyMap<string, string>>,
1036
1050
  ): void {
1037
1051
  const ringColor = ruleLocalVars.get('--tw-ring-color')
1038
1052
  if (!ringColor) return
1039
- const color = resolveCustomColorString(ringColor)
1040
- if (!color) return
1041
1053
  for (const scheme of schemes) {
1054
+ // Resolve per scheme — a custom token may differ between light/dark.
1055
+ const color = resolveCustomColorString(ringColor, schemeTables.get(scheme))
1056
+ if (!color) continue
1042
1057
  const style = bucket[scheme] ?? {}
1043
1058
  if (!('borderColor' in style)) style.borderColor = color
1044
1059
  bucket[scheme] = style
1045
1060
  }
1046
1061
  }
1047
1062
 
1063
+ /**
1064
+ * Tailwind composable shadow/inset-shadow alpha defaults. Their `100%` lives
1065
+ * in an `@property` initial-value (not the rule's local vars), so after the
1066
+ * `@supports` color-mix is unwrapped, `var(--tw-shadow-alpha)` is left dangling
1067
+ * and the shadow color fails to resolve. Seed the default; a `/<opacity>`
1068
+ * modifier still wins because the in-rule table value overrides it.
1069
+ */
1070
+ const COMPOSABLE_ALPHA_DEFAULTS: ReadonlyMap<string, string> = new Map([
1071
+ ['--tw-shadow-alpha', '100%'],
1072
+ ['--tw-inset-shadow-alpha', '100%'],
1073
+ ])
1074
+
1048
1075
  /**
1049
1076
  * Resolve a CSS color string (`oklch(0.971 0.013 17.38)`, `#ff0000`,
1050
1077
  * `rgb(0 0 0 / 0.1)`) to the hex string RN's `shadowColor` accepts.
1051
1078
  * Wraps culori's parser via {@link parseCssColorToHex}.
1052
- * @param raw Raw color text from a `--tw-shadow-color` custom prop.
1079
+ *
1080
+ * Custom `@theme` color tokens arrive as `var(--color-x)` (only the default
1081
+ * palette is `theme(inline)`-d), so `table` is substituted FIRST — without it
1082
+ * `shadow-<token>` / `ring-<token>` silently drop the color (culori can't
1083
+ * parse a bare `var()`). The table is per-scheme so a token that differs
1084
+ * between light/dark resolves to the right value for each.
1085
+ * @param raw Raw color text from a `--tw-shadow-color` / `--tw-ring-color` prop.
1086
+ * @param table Per-scheme var table for resolving `var(--color-x)` references.
1053
1087
  * @returns `#rrggbb` string, or null when culori can't parse it.
1054
1088
  */
1055
- function resolveCustomColorString(raw: string): string | null {
1056
- const text = unwrapVariableFallback(raw).trim()
1057
- if (text.length === 0) return null
1058
- if (text.startsWith('#')) return text
1059
- return parseCssColorToHex(text)
1089
+ function resolveCustomColorString(raw: string, table?: ReadonlyMap<string, string>): string | null {
1090
+ const seeded = new Map([...COMPOSABLE_ALPHA_DEFAULTS, ...(table ?? [])])
1091
+ const substituted = substituteThemeVars(raw, seeded)
1092
+ // `coerceUnparsedValue` collapses Tailwind's opacity shape
1093
+ // `color-mix(in oklab, <color> <pct>%, transparent)` (emitted by
1094
+ // `shadow-<token>` / `ring-<token>`) to a flat rgba/hex and unwraps
1095
+ // `var(…, fallback)`. Modern spaces (`oklch(…)`) then lower via
1096
+ // `normalizeColorString`; anything still un-RN-safe falls to culori.
1097
+ const coerced = coerceUnparsedValue(unwrapVariableFallback(substituted).trim())
1098
+ if (typeof coerced !== 'string' || coerced.length === 0 || coerced.startsWith('var(')) return null
1099
+ if (coerced.startsWith('#') || coerced.startsWith('rgb') || coerced.startsWith('hsl')) return coerced
1100
+ return normalizeColorString(coerced) ?? parseCssColorToHex(coerced)
1060
1101
  }
1061
1102
 
1062
1103
  /**
@@ -1168,6 +1209,11 @@ function parseShadowColor(expr: string): { color: string; opacity: number } {
1168
1209
  const rgba = parseRgbaExpression(working)
1169
1210
  if (rgba) return rgba
1170
1211
  if (working.startsWith('#')) return { color: working, opacity: 1 }
1212
+ // Named (`red`) / modern (`hsl(…)`, `oklch(…)`) colors — culori → sRGB hex.
1213
+ // Without this they fell to the default black at 0.1 alpha, silently losing
1214
+ // the user's `shadow-[0_2px_4px_red]` color.
1215
+ const hex = formatHexSafe(working)
1216
+ if (hex) return { color: hex, opacity: 1 }
1171
1217
  return { color: '#000', opacity: 0.1 }
1172
1218
  }
1173
1219
 
@@ -1379,8 +1425,8 @@ function resolveLengthExpression(text: string): number | string | null {
1379
1425
  const evaluated = evaluateLengthExpr(trimmed)
1380
1426
  if (!evaluated) return null
1381
1427
  if (evaluated.unit === '%') return `${stripTrailingZeros(evaluated.value)}%`
1382
- if (evaluated.unit === 'rem') return evaluated.value * 16
1383
- return evaluated.value
1428
+ if (evaluated.unit === 'rem') return roundTransformValue(evaluated.value * 16)
1429
+ return roundTransformValue(evaluated.value)
1384
1430
  }
1385
1431
 
1386
1432
  /** Evaluated length + its unit. `''` means px or bare number. */
@@ -1607,17 +1653,36 @@ function parseArithmeticFactor(tokens: readonly string[], cursor: { index: numbe
1607
1653
  }
1608
1654
 
1609
1655
  /**
1610
- * Resolve a scale factor expressed as a percentage (`150%`) or number (`1.5`).
1656
+ * Resolve a scale factor expressed as a percentage (`150%`), number (`1.5`),
1657
+ * or a `calc()` expression. Tailwind emits NEGATIVE scale utilities as a calc
1658
+ * (`-scale-x-100` → `calc(100% * -1)`), so a plain percent/number regex
1659
+ * silently dropped them — `-scale-*` (the horizontal-flip idiom) rendered
1660
+ * nothing. Fall back to the shared arithmetic evaluator, reading `%` as a
1661
+ * fraction (`100%` → 1) and rounding off f32 noise.
1611
1662
  * @param text Raw value.
1612
- * @returns Scale number (e.g. 1.5 for 150%), or null.
1663
+ * @returns Scale number (e.g. 1.5 for 150%, -1 for `calc(100% * -1)`), or null.
1613
1664
  */
1614
1665
  function resolveNumberOrPercent(text: string): number | null {
1615
1666
  const trimmed = text.trim()
1616
1667
  const percent = /^(-?\d+(?:\.\d+)?)%$/.exec(trimmed)
1617
- if (percent) return Number(percent[1]) / 100
1668
+ if (percent) return roundTransformValue(Number(percent[1]) / 100)
1618
1669
  const bare = /^-?\d+(?:\.\d+)?$/.exec(trimmed)
1619
- if (bare) return Number(trimmed)
1620
- return null
1670
+ if (bare) return roundTransformValue(Number(trimmed))
1671
+ const evaluated = evaluateLengthExpr(trimmed)
1672
+ if (!evaluated || evaluated.unit === 'rem') return null
1673
+ return roundTransformValue(evaluated.unit === '%' ? evaluated.value / 100 : evaluated.value)
1674
+ }
1675
+
1676
+ /**
1677
+ * Round a composed-transform numeric value to 4 decimals. lightningcss
1678
+ * serializes arbitrary literals (`scale-x-[0.333]`) back as noisy f32 text
1679
+ * (`0.3330000042915344`), and the resolvers `Number()` that verbatim — round
1680
+ * so the RN `transform` array stays clean.
1681
+ * @param value Raw number.
1682
+ * @returns Rounded number.
1683
+ */
1684
+ function roundTransformValue(value: number): number {
1685
+ return Math.round(value * 10_000) / 10_000
1621
1686
  }
1622
1687
 
1623
1688
  /**
@@ -1,7 +1,11 @@
1
1
  import type { Declaration as LcDeclaration } from 'lightningcss'
2
2
  import { lineHeightToEntries } from './typography'
3
+ import { firstConcreteFontFamily } from './tokens'
3
4
  import type { RNEntry } from './types'
4
5
 
6
+ /** RN-supported `textDecorationStyle` values (`wavy` has no RN equivalent). */
7
+ const RN_DECORATION_STYLES: ReadonlySet<string> = new Set(['solid', 'double', 'dotted', 'dashed'])
8
+
5
9
  /**
6
10
  * Build the RN `textDecorationLine` entry — string identity for the
7
11
  * single-line cases, joined-string for the array shape.
@@ -45,7 +49,9 @@ function letterSpacingToEntries(value: LcDeclaration['value']): readonly RNEntry
45
49
  if (inner?.type !== 'value' || !inner.value) return []
46
50
  const { unit, value: px } = inner.value
47
51
  if (typeof px !== 'number') return []
48
- return [['letterSpacing', unit === 'px' ? px : px * 16]]
52
+ const resolved = unit === 'px' ? px : px * 16
53
+ // Round off lightningcss f32 noise (`0.1em` → `1.600000023841858`).
54
+ return [['letterSpacing', Math.round(resolved * 10_000) / 10_000]]
49
55
  }
50
56
 
51
57
  /**
@@ -81,6 +87,19 @@ export function dispatchTypographyDeclaration(decl: LcDeclaration): readonly RNE
81
87
  case 'text-decoration-line': {
82
88
  return textDecorationLineToEntries(decl.value)
83
89
  }
90
+ case 'text-decoration-style': {
91
+ // RN <Text> supports textDecorationStyle (solid/double/dotted/dashed).
92
+ const style = String(decl.value)
93
+ return RN_DECORATION_STYLES.has(style) ? [['textDecorationStyle', style]] : []
94
+ }
95
+ case 'font-family': {
96
+ // Typed `font-family` is a fallback LIST (`font-sans`, `font-mono`,
97
+ // `font-[Inter]`). RN takes one concrete typeface; an all-generic
98
+ // stack (default `font-sans`) emits nothing → system font. The themed
99
+ // `var(--font-*)` path goes through `coerceFontFamily` in declaration.ts.
100
+ const family = firstConcreteFontFamily(decl.value as readonly unknown[])
101
+ return family === undefined ? [] : [['fontFamily', family]]
102
+ }
84
103
  case 'aspect-ratio': {
85
104
  return aspectRatioToEntries(decl.value)
86
105
  }