rnwind 0.0.8 → 0.0.10

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 (145) hide show
  1. package/lib/cjs/core/parser/color.cjs +33 -1
  2. package/lib/cjs/core/parser/color.cjs.map +1 -1
  3. package/lib/cjs/core/parser/color.d.ts +10 -0
  4. package/lib/cjs/core/parser/declaration.cjs +121 -9
  5. package/lib/cjs/core/parser/declaration.cjs.map +1 -1
  6. package/lib/cjs/core/parser/gradient.cjs +46 -12
  7. package/lib/cjs/core/parser/gradient.cjs.map +1 -1
  8. package/lib/cjs/core/parser/gradient.d.ts +2 -1
  9. package/lib/cjs/core/parser/keyframes.cjs +27 -12
  10. package/lib/cjs/core/parser/keyframes.cjs.map +1 -1
  11. package/lib/cjs/core/parser/keyframes.d.ts +11 -0
  12. package/lib/cjs/core/parser/layout-dispatcher.cjs +33 -10
  13. package/lib/cjs/core/parser/layout-dispatcher.cjs.map +1 -1
  14. package/lib/cjs/core/parser/length.cjs +17 -1
  15. package/lib/cjs/core/parser/length.cjs.map +1 -1
  16. package/lib/cjs/core/parser/safe-area.cjs +24 -3
  17. package/lib/cjs/core/parser/safe-area.cjs.map +1 -1
  18. package/lib/cjs/core/parser/theme-vars.cjs +58 -8
  19. package/lib/cjs/core/parser/theme-vars.cjs.map +1 -1
  20. package/lib/cjs/core/parser/tokens.cjs +77 -9
  21. package/lib/cjs/core/parser/tokens.cjs.map +1 -1
  22. package/lib/cjs/core/parser/tokens.d.ts +9 -0
  23. package/lib/cjs/core/parser/transform.cjs +18 -9
  24. package/lib/cjs/core/parser/transform.cjs.map +1 -1
  25. package/lib/cjs/core/parser/tw-parser.cjs +136 -34
  26. package/lib/cjs/core/parser/tw-parser.cjs.map +1 -1
  27. package/lib/cjs/core/parser/tw-parser.d.ts +20 -0
  28. package/lib/cjs/core/parser/typography-dispatcher.cjs +19 -1
  29. package/lib/cjs/core/parser/typography-dispatcher.cjs.map +1 -1
  30. package/lib/cjs/core/parser/typography.cjs +15 -18
  31. package/lib/cjs/core/parser/typography.cjs.map +1 -1
  32. package/lib/cjs/core/parser/typography.d.ts +5 -5
  33. package/lib/cjs/core/style-builder/build-style.cjs +12 -3
  34. package/lib/cjs/core/style-builder/build-style.cjs.map +1 -1
  35. package/lib/cjs/core/style-builder/build-style.d.ts +3 -1
  36. package/lib/cjs/core/style-builder/union-builder.cjs +9 -11
  37. package/lib/cjs/core/style-builder/union-builder.cjs.map +1 -1
  38. package/lib/cjs/core/style-builder/union-builder.d.ts +7 -8
  39. package/lib/cjs/metro/dts.cjs +6 -1
  40. package/lib/cjs/metro/dts.cjs.map +1 -1
  41. package/lib/cjs/metro/transformer.cjs +42 -77
  42. package/lib/cjs/metro/transformer.cjs.map +1 -1
  43. package/lib/cjs/metro/with-config.cjs +9 -29
  44. package/lib/cjs/metro/with-config.cjs.map +1 -1
  45. package/lib/cjs/runtime/hooks/use-scheme.cjs +17 -11
  46. package/lib/cjs/runtime/hooks/use-scheme.cjs.map +1 -1
  47. package/lib/cjs/runtime/hooks/use-scheme.d.ts +7 -4
  48. package/lib/cjs/runtime/index.cjs +2 -1
  49. package/lib/cjs/runtime/index.cjs.map +1 -1
  50. package/lib/cjs/runtime/index.d.ts +2 -2
  51. package/lib/cjs/runtime/lookup-css.cjs +41 -0
  52. package/lib/cjs/runtime/lookup-css.cjs.map +1 -1
  53. package/lib/cjs/runtime/lookup-css.d.ts +29 -0
  54. package/lib/cjs/runtime/resolve.cjs +8 -6
  55. package/lib/cjs/runtime/resolve.cjs.map +1 -1
  56. package/lib/cjs/runtime/wrap.cjs +50 -57
  57. package/lib/cjs/runtime/wrap.cjs.map +1 -1
  58. package/lib/cjs/runtime/wrap.d.ts +10 -4
  59. package/lib/cjs/testing/index.cjs +1 -1
  60. package/lib/cjs/testing/index.cjs.map +1 -1
  61. package/lib/esm/core/parser/color.d.ts +10 -0
  62. package/lib/esm/core/parser/color.mjs +34 -3
  63. package/lib/esm/core/parser/color.mjs.map +1 -1
  64. package/lib/esm/core/parser/declaration.mjs +122 -10
  65. package/lib/esm/core/parser/declaration.mjs.map +1 -1
  66. package/lib/esm/core/parser/gradient.d.ts +2 -1
  67. package/lib/esm/core/parser/gradient.mjs +45 -11
  68. package/lib/esm/core/parser/gradient.mjs.map +1 -1
  69. package/lib/esm/core/parser/keyframes.d.ts +11 -0
  70. package/lib/esm/core/parser/keyframes.mjs +27 -12
  71. package/lib/esm/core/parser/keyframes.mjs.map +1 -1
  72. package/lib/esm/core/parser/layout-dispatcher.mjs +33 -10
  73. package/lib/esm/core/parser/layout-dispatcher.mjs.map +1 -1
  74. package/lib/esm/core/parser/length.mjs +17 -1
  75. package/lib/esm/core/parser/length.mjs.map +1 -1
  76. package/lib/esm/core/parser/safe-area.mjs +24 -3
  77. package/lib/esm/core/parser/safe-area.mjs.map +1 -1
  78. package/lib/esm/core/parser/theme-vars.mjs +58 -8
  79. package/lib/esm/core/parser/theme-vars.mjs.map +1 -1
  80. package/lib/esm/core/parser/tokens.d.ts +9 -0
  81. package/lib/esm/core/parser/tokens.mjs +77 -10
  82. package/lib/esm/core/parser/tokens.mjs.map +1 -1
  83. package/lib/esm/core/parser/transform.mjs +18 -9
  84. package/lib/esm/core/parser/transform.mjs.map +1 -1
  85. package/lib/esm/core/parser/tw-parser.d.ts +20 -0
  86. package/lib/esm/core/parser/tw-parser.mjs +138 -36
  87. package/lib/esm/core/parser/tw-parser.mjs.map +1 -1
  88. package/lib/esm/core/parser/typography-dispatcher.mjs +19 -1
  89. package/lib/esm/core/parser/typography-dispatcher.mjs.map +1 -1
  90. package/lib/esm/core/parser/typography.d.ts +5 -5
  91. package/lib/esm/core/parser/typography.mjs +15 -18
  92. package/lib/esm/core/parser/typography.mjs.map +1 -1
  93. package/lib/esm/core/style-builder/build-style.d.ts +3 -1
  94. package/lib/esm/core/style-builder/build-style.mjs +12 -3
  95. package/lib/esm/core/style-builder/build-style.mjs.map +1 -1
  96. package/lib/esm/core/style-builder/union-builder.d.ts +7 -8
  97. package/lib/esm/core/style-builder/union-builder.mjs +9 -11
  98. package/lib/esm/core/style-builder/union-builder.mjs.map +1 -1
  99. package/lib/esm/metro/dts.mjs +6 -1
  100. package/lib/esm/metro/dts.mjs.map +1 -1
  101. package/lib/esm/metro/transformer.mjs +42 -77
  102. package/lib/esm/metro/transformer.mjs.map +1 -1
  103. package/lib/esm/metro/with-config.mjs +10 -30
  104. package/lib/esm/metro/with-config.mjs.map +1 -1
  105. package/lib/esm/runtime/hooks/use-scheme.d.ts +7 -4
  106. package/lib/esm/runtime/hooks/use-scheme.mjs +17 -11
  107. package/lib/esm/runtime/hooks/use-scheme.mjs.map +1 -1
  108. package/lib/esm/runtime/index.d.ts +2 -2
  109. package/lib/esm/runtime/index.mjs +2 -2
  110. package/lib/esm/runtime/index.mjs.map +1 -1
  111. package/lib/esm/runtime/lookup-css.d.ts +29 -0
  112. package/lib/esm/runtime/lookup-css.mjs +39 -1
  113. package/lib/esm/runtime/lookup-css.mjs.map +1 -1
  114. package/lib/esm/runtime/resolve.mjs +9 -7
  115. package/lib/esm/runtime/resolve.mjs.map +1 -1
  116. package/lib/esm/runtime/wrap.d.ts +10 -4
  117. package/lib/esm/runtime/wrap.mjs +50 -57
  118. package/lib/esm/runtime/wrap.mjs.map +1 -1
  119. package/lib/esm/testing/index.mjs +2 -2
  120. package/lib/esm/testing/index.mjs.map +1 -1
  121. package/package.json +1 -1
  122. package/src/core/parser/color.ts +32 -1
  123. package/src/core/parser/declaration.ts +119 -10
  124. package/src/core/parser/gradient.ts +48 -11
  125. package/src/core/parser/keyframes.ts +31 -3
  126. package/src/core/parser/layout-dispatcher.ts +32 -9
  127. package/src/core/parser/length.ts +18 -1
  128. package/src/core/parser/safe-area.ts +23 -2
  129. package/src/core/parser/theme-vars.ts +75 -8
  130. package/src/core/parser/tokens.ts +76 -9
  131. package/src/core/parser/transform.ts +19 -8
  132. package/src/core/parser/tw-parser.ts +148 -31
  133. package/src/core/parser/typography-dispatcher.ts +20 -1
  134. package/src/core/parser/typography.ts +15 -15
  135. package/src/core/style-builder/build-style.ts +12 -1
  136. package/src/core/style-builder/union-builder.ts +10 -12
  137. package/src/metro/dts.ts +6 -1
  138. package/src/metro/transformer.ts +42 -78
  139. package/src/metro/with-config.ts +10 -29
  140. package/src/runtime/hooks/use-scheme.ts +17 -10
  141. package/src/runtime/index.ts +2 -1
  142. package/src/runtime/lookup-css.ts +42 -0
  143. package/src/runtime/resolve.ts +9 -7
  144. package/src/runtime/wrap.tsx +57 -61
  145. package/src/testing/index.ts +3 -0
@@ -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,8 +16,10 @@ 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'
22
+ import type { ThemeTable, ThemeTables } from '../types'
21
23
  import type { Declaration as LcDeclaration, TokenOrValue } from 'lightningcss'
22
24
 
23
25
  /**
@@ -152,6 +154,14 @@ export interface ParsedOutput {
152
154
  * provider's `windowWidth`.
153
155
  */
154
156
  breakpoints: ReadonlyMap<string, number>
157
+ /**
158
+ * Per-scheme user theme token tables (`--color-*`, `--spacing-*`, …) with
159
+ * `--color-*` values lowered to sRGB. The style-builder emits these as
160
+ * `registerThemeTokens({...})` in the manifest so `useColor` / `useToken` /
161
+ * `useSize` resolve out of the box, without the user threading a `tables`
162
+ * prop on the provider. Keyed by scheme (`base` + each declared variant).
163
+ */
164
+ themeTokens: ThemeTables
155
165
  }
156
166
 
157
167
  /**
@@ -259,6 +269,33 @@ export class TailwindParser {
259
269
  return merged
260
270
  }
261
271
 
272
+ /**
273
+ * Build the per-scheme theme token tables for `registerThemeTokens` —
274
+ * the data source for `useColor` / `useToken` / `useSize`. Emits one table
275
+ * per scheme the user wrote tokens under (`base` + each variant block /
276
+ * `.dark` override). `--color-*` values are lowered to sRGB (matching the
277
+ * className path) using `resolver` so a wide-gamut or `var()`-referencing
278
+ * token resolves to an RN-renderable string; other tokens pass through.
279
+ * @param resolver Full compiled `:root` table, for resolving `var()` refs.
280
+ * @returns Scheme → (token name → value) map.
281
+ */
282
+ private buildThemeTokens(resolver: ReadonlyMap<string, string>): ThemeTables {
283
+ const out: ThemeTables = {}
284
+ for (const scheme of this.themeSchemes.keys()) {
285
+ const userTable = this.themeSchemes.get(scheme)
286
+ if (!userTable || userTable.size === 0) continue
287
+
288
+ const schemeResolver = new Map(resolver)
289
+ for (const [k, v] of this.effectiveVars(scheme)) schemeResolver.set(k, v)
290
+ const table: ThemeTable = {}
291
+ for (const [name, raw] of userTable) {
292
+ table[name] = name.startsWith('--color-') ? lowerColorToken(raw, schemeResolver) : raw
293
+ }
294
+ out[scheme] = table
295
+ }
296
+ return out
297
+ }
298
+
262
299
  /**
263
300
  * Build the Tailwind compiler on first use and cache it. The theme CSS
264
301
  * gets a `theme(inline)` modifier on its `@import 'tailwindcss'` so
@@ -398,7 +435,8 @@ export class TailwindParser {
398
435
  // surface their role + resolved colour so the transformer
399
436
  // can rewrite `<LinearGradient className="...">` into
400
437
  // `colors={...}` / `start={...}` / `end={...}` props.
401
- const gradient = detectGradientAtom(rule.value.declarations.declarations)
438
+ const gradientTable = schemeTables.get(BASE_SCHEME) ?? schemeTables.get(schemes[0] ?? BASE_SCHEME)
439
+ const gradient = detectGradientAtom(rule.value.declarations.declarations, gradientTable)
402
440
  if (gradient) gradientAtoms.set(className, gradient)
403
441
  // Haptics may live on the rule directly OR inside a
404
442
  // nested pseudo (e.g. `&:active` for `active:haptic-*`).
@@ -415,14 +453,16 @@ export class TailwindParser {
415
453
  const steps: KeyframeStep[] = []
416
454
  const baseTable = schemeTables.get(BASE_SCHEME) ?? schemeTables.get(schemes[0] ?? BASE_SCHEME)
417
455
  for (const frame of rule.value.keyframes) {
418
- const offset = keyframeSelectorOffset(frame.selectors)
419
- if (!offset) continue
456
+ const offsets = keyframeSelectorOffsets(frame.selectors)
457
+ if (offsets.length === 0) continue
420
458
  const style: RNStyle = {}
421
459
  const frameDecls = frame.declarations.declarations ?? []
422
460
  for (const decl of frameDecls) {
423
461
  for (const [key, value] of declarationToRnEntries(decl, baseTable)) style[key] = value
424
462
  }
425
- steps.push({ offset, style })
463
+ // One frame can carry several offsets (`0%, 100% { }`); emit a
464
+ // step for each so the terminal frame isn't lost.
465
+ for (const offset of offsets) steps.push({ offset, style })
426
466
  }
427
467
  keyframes.set(name, { name, steps })
428
468
  },
@@ -442,10 +482,25 @@ export class TailwindParser {
442
482
  if (!referencedKeyframes.has(name)) keyframes.delete(name)
443
483
  }
444
484
 
445
- return { atoms, keyframes, propertyDefaults, gradientAtoms, hapticAtoms, candidates: [...candidates], schemes, breakpoints }
485
+ const themeTokens = this.buildThemeTokens(compiledTheme)
486
+ return { atoms, keyframes, propertyDefaults, gradientAtoms, hapticAtoms, candidates: [...candidates], schemes, breakpoints, themeTokens }
446
487
  }
447
488
  }
448
489
 
490
+ /**
491
+ * Lower a `--color-*` token value to an RN-renderable sRGB string, matching
492
+ * the className path: resolve any `var()` ref via `resolver`, then lower a
493
+ * wide-gamut form (`oklch(…)`, `lab(…)`, `color(p3 …)`) to sRGB. Hex / rgb /
494
+ * named colors pass through unchanged.
495
+ * @param raw Raw token value.
496
+ * @param resolver Var name → value table for resolving `var()` references.
497
+ * @returns RN-safe color string.
498
+ */
499
+ function lowerColorToken(raw: string, resolver: ReadonlyMap<string, string>): string {
500
+ const substituted = substituteThemeVars(raw, resolver)
501
+ return normalizeColorString(substituted) ?? substituted
502
+ }
503
+
449
504
  /**
450
505
  * Wrap an error from `@tailwindcss/node`'s compiler or `lightningcss`'s
451
506
  * transform with a `rnwind:` prefix so the user sees a clear "this came
@@ -493,6 +548,7 @@ function emptyOutput(): ParsedOutput {
493
548
  candidates: [],
494
549
  schemes: [BASE_SCHEME],
495
550
  breakpoints: new Map(),
551
+ themeTokens: {},
496
552
  }
497
553
  }
498
554
 
@@ -582,8 +638,8 @@ function processStyleRule(
582
638
  if (animationRef) ctx.referencedKeyframes.add(animationRef)
583
639
  }
584
640
  applyComposedTransform(bucket, ctx.schemes, ruleLocalVars)
585
- applyComposedShadow(bucket, ctx.schemes, ruleLocalVars)
586
- applyComposedRing(bucket, ctx.schemes, ruleLocalVars)
641
+ applyComposedShadow(bucket, ctx.schemes, ruleLocalVars, ruleSchemeTables)
642
+ applyComposedRing(bucket, ctx.schemes, ruleLocalVars, ruleSchemeTables)
587
643
  // Phase 2: nested rules — three orthogonal flavours, dispatched on
588
644
  // the lightningcss node `type`:
589
645
  // - `media`: Tailwind v4 responsive variants (`sm:`, `md:`, …) wrap
@@ -729,7 +785,7 @@ function applyMediaRule(
729
785
  const nestedLocalVars = new Map(ruleLocalVars)
730
786
  for (const [k, v] of collectRuleLocalVars(decls)) nestedLocalVars.set(k, v)
731
787
  applyComposedTransformToScheme(schemeBucket, nestedLocalVars)
732
- applyComposedShadowToScheme(schemeBucket, nestedLocalVars)
788
+ applyComposedShadowToScheme(schemeBucket, nestedLocalVars, table)
733
789
  bucket[scheme] = schemeBucket
734
790
  }
735
791
  }
@@ -769,7 +825,7 @@ function applyInteractiveNestedRule(
769
825
  const nestedLocalVars = new Map(ruleLocalVars)
770
826
  for (const [k, v] of collectRuleLocalVars(decls)) nestedLocalVars.set(k, v)
771
827
  applyComposedTransformToScheme(schemeBucket, nestedLocalVars)
772
- applyComposedShadowToScheme(schemeBucket, nestedLocalVars)
828
+ applyComposedShadowToScheme(schemeBucket, nestedLocalVars, table)
773
829
  bucket[scheme] = schemeBucket
774
830
  }
775
831
  }
@@ -832,7 +888,7 @@ function applyNestedSchemeRule(
832
888
  const nestedLocalVars = new Map(ruleLocalVars)
833
889
  for (const [k, v] of collectRuleLocalVars(innerDecls)) nestedLocalVars.set(k, v)
834
890
  applyComposedTransformToScheme(schemeBucket, nestedLocalVars)
835
- applyComposedShadowToScheme(schemeBucket, nestedLocalVars)
891
+ applyComposedShadowToScheme(schemeBucket, nestedLocalVars, table)
836
892
  bucket[targetScheme] = schemeBucket
837
893
  }
838
894
 
@@ -948,12 +1004,17 @@ function applyComposedTransformToScheme(style: RNStyle, ruleLocalVars: ReadonlyM
948
1004
  * prop.
949
1005
  * @param style Scheme-specific style map.
950
1006
  * @param ruleLocalVars Combined outer+nested `--tw-*` vars.
1007
+ * @param table Per-scheme var table for resolving `var(--color-x)` in colors.
951
1008
  */
952
- function applyComposedShadowToScheme(style: RNStyle, ruleLocalVars: ReadonlyMap<string, string>): void {
1009
+ function applyComposedShadowToScheme(
1010
+ style: RNStyle,
1011
+ ruleLocalVars: ReadonlyMap<string, string>,
1012
+ table?: ReadonlyMap<string, string>,
1013
+ ): void {
953
1014
  const rawShadow = ruleLocalVars.get('--tw-shadow')
954
1015
  const rawShadowColor = ruleLocalVars.get('--tw-shadow-color')
955
1016
  if (!rawShadow && rawShadowColor) {
956
- const color = resolveCustomColorString(rawShadowColor)
1017
+ const color = resolveCustomColorString(rawShadowColor, table)
957
1018
  if (!color) return
958
1019
  delete style.boxShadow
959
1020
  style.shadowColor = color
@@ -980,11 +1041,13 @@ function applyComposedShadowToScheme(style: RNStyle, ruleLocalVars: ReadonlyMap<
980
1041
  * @param bucket Per-scheme style map for the atom.
981
1042
  * @param schemes Scheme names active for this parse.
982
1043
  * @param ruleLocalVars Rule-local `--tw-*` vars.
1044
+ * @param schemeTables Per-scheme var tables for resolving `var(--color-x)`.
983
1045
  */
984
1046
  function applyComposedShadow(
985
1047
  bucket: Record<string, RNStyle>,
986
1048
  schemes: readonly string[],
987
1049
  ruleLocalVars: ReadonlyMap<string, string>,
1050
+ schemeTables: ReadonlyMap<string, ReadonlyMap<string, string>>,
988
1051
  ): void {
989
1052
  const rawShadow = ruleLocalVars.get('--tw-shadow')
990
1053
  const rawShadowColor = ruleLocalVars.get('--tw-shadow-color')
@@ -994,9 +1057,10 @@ function applyComposedShadow(
994
1057
  // where setting `--tw-shadow-color` swaps in a solid color). Offset /
995
1058
  // blur / elevation come from the partner size utility's atom.
996
1059
  if (!rawShadow && rawShadowColor) {
997
- const color = resolveCustomColorString(rawShadowColor)
998
- if (!color) return
999
1060
  for (const scheme of schemes) {
1061
+ // Resolve per scheme — a custom token may differ between light/dark.
1062
+ const color = resolveCustomColorString(rawShadowColor, schemeTables.get(scheme))
1063
+ if (!color) continue
1000
1064
  const style = bucket[scheme] ?? {}
1001
1065
  delete style.boxShadow
1002
1066
  style.shadowColor = color
@@ -1028,35 +1092,64 @@ function applyComposedShadow(
1028
1092
  * @param bucket Per-scheme style map for the atom.
1029
1093
  * @param schemes Scheme names active for this parse.
1030
1094
  * @param ruleLocalVars Rule-local `--tw-*` vars.
1095
+ * @param schemeTables Per-scheme var tables for resolving `var(--color-x)`.
1031
1096
  */
1032
1097
  function applyComposedRing(
1033
1098
  bucket: Record<string, RNStyle>,
1034
1099
  schemes: readonly string[],
1035
1100
  ruleLocalVars: ReadonlyMap<string, string>,
1101
+ schemeTables: ReadonlyMap<string, ReadonlyMap<string, string>>,
1036
1102
  ): void {
1037
1103
  const ringColor = ruleLocalVars.get('--tw-ring-color')
1038
1104
  if (!ringColor) return
1039
- const color = resolveCustomColorString(ringColor)
1040
- if (!color) return
1041
1105
  for (const scheme of schemes) {
1106
+ // Resolve per scheme — a custom token may differ between light/dark.
1107
+ const color = resolveCustomColorString(ringColor, schemeTables.get(scheme))
1108
+ if (!color) continue
1042
1109
  const style = bucket[scheme] ?? {}
1043
1110
  if (!('borderColor' in style)) style.borderColor = color
1044
1111
  bucket[scheme] = style
1045
1112
  }
1046
1113
  }
1047
1114
 
1115
+ /**
1116
+ * Tailwind composable shadow/inset-shadow alpha defaults. Their `100%` lives
1117
+ * in an `@property` initial-value (not the rule's local vars), so after the
1118
+ * `@supports` color-mix is unwrapped, `var(--tw-shadow-alpha)` is left dangling
1119
+ * and the shadow color fails to resolve. Seed the default; a `/<opacity>`
1120
+ * modifier still wins because the in-rule table value overrides it.
1121
+ */
1122
+ const COMPOSABLE_ALPHA_DEFAULTS: ReadonlyMap<string, string> = new Map([
1123
+ ['--tw-shadow-alpha', '100%'],
1124
+ ['--tw-inset-shadow-alpha', '100%'],
1125
+ ])
1126
+
1048
1127
  /**
1049
1128
  * Resolve a CSS color string (`oklch(0.971 0.013 17.38)`, `#ff0000`,
1050
1129
  * `rgb(0 0 0 / 0.1)`) to the hex string RN's `shadowColor` accepts.
1051
1130
  * Wraps culori's parser via {@link parseCssColorToHex}.
1052
- * @param raw Raw color text from a `--tw-shadow-color` custom prop.
1131
+ *
1132
+ * Custom `@theme` color tokens arrive as `var(--color-x)` (only the default
1133
+ * palette is `theme(inline)`-d), so `table` is substituted FIRST — without it
1134
+ * `shadow-<token>` / `ring-<token>` silently drop the color (culori can't
1135
+ * parse a bare `var()`). The table is per-scheme so a token that differs
1136
+ * between light/dark resolves to the right value for each.
1137
+ * @param raw Raw color text from a `--tw-shadow-color` / `--tw-ring-color` prop.
1138
+ * @param table Per-scheme var table for resolving `var(--color-x)` references.
1053
1139
  * @returns `#rrggbb` string, or null when culori can't parse it.
1054
1140
  */
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)
1141
+ function resolveCustomColorString(raw: string, table?: ReadonlyMap<string, string>): string | null {
1142
+ const seeded = new Map([...COMPOSABLE_ALPHA_DEFAULTS, ...(table ?? [])])
1143
+ const substituted = substituteThemeVars(raw, seeded)
1144
+ // `coerceUnparsedValue` collapses Tailwind's opacity shape
1145
+ // `color-mix(in oklab, <color> <pct>%, transparent)` (emitted by
1146
+ // `shadow-<token>` / `ring-<token>`) to a flat rgba/hex and unwraps
1147
+ // `var(…, fallback)`. Modern spaces (`oklch(…)`) then lower via
1148
+ // `normalizeColorString`; anything still un-RN-safe falls to culori.
1149
+ const coerced = coerceUnparsedValue(unwrapVariableFallback(substituted).trim())
1150
+ if (typeof coerced !== 'string' || coerced.length === 0 || coerced.startsWith('var(')) return null
1151
+ if (coerced.startsWith('#') || coerced.startsWith('rgb') || coerced.startsWith('hsl')) return coerced
1152
+ return normalizeColorString(coerced) ?? parseCssColorToHex(coerced)
1060
1153
  }
1061
1154
 
1062
1155
  /**
@@ -1168,6 +1261,11 @@ function parseShadowColor(expr: string): { color: string; opacity: number } {
1168
1261
  const rgba = parseRgbaExpression(working)
1169
1262
  if (rgba) return rgba
1170
1263
  if (working.startsWith('#')) return { color: working, opacity: 1 }
1264
+ // Named (`red`) / modern (`hsl(…)`, `oklch(…)`) colors — culori → sRGB hex.
1265
+ // Without this they fell to the default black at 0.1 alpha, silently losing
1266
+ // the user's `shadow-[0_2px_4px_red]` color.
1267
+ const hex = formatHexSafe(working)
1268
+ if (hex) return { color: hex, opacity: 1 }
1171
1269
  return { color: '#000', opacity: 0.1 }
1172
1270
  }
1173
1271
 
@@ -1379,8 +1477,8 @@ function resolveLengthExpression(text: string): number | string | null {
1379
1477
  const evaluated = evaluateLengthExpr(trimmed)
1380
1478
  if (!evaluated) return null
1381
1479
  if (evaluated.unit === '%') return `${stripTrailingZeros(evaluated.value)}%`
1382
- if (evaluated.unit === 'rem') return evaluated.value * 16
1383
- return evaluated.value
1480
+ if (evaluated.unit === 'rem') return roundTransformValue(evaluated.value * 16)
1481
+ return roundTransformValue(evaluated.value)
1384
1482
  }
1385
1483
 
1386
1484
  /** Evaluated length + its unit. `''` means px or bare number. */
@@ -1607,17 +1705,36 @@ function parseArithmeticFactor(tokens: readonly string[], cursor: { index: numbe
1607
1705
  }
1608
1706
 
1609
1707
  /**
1610
- * Resolve a scale factor expressed as a percentage (`150%`) or number (`1.5`).
1708
+ * Resolve a scale factor expressed as a percentage (`150%`), number (`1.5`),
1709
+ * or a `calc()` expression. Tailwind emits NEGATIVE scale utilities as a calc
1710
+ * (`-scale-x-100` → `calc(100% * -1)`), so a plain percent/number regex
1711
+ * silently dropped them — `-scale-*` (the horizontal-flip idiom) rendered
1712
+ * nothing. Fall back to the shared arithmetic evaluator, reading `%` as a
1713
+ * fraction (`100%` → 1) and rounding off f32 noise.
1611
1714
  * @param text Raw value.
1612
- * @returns Scale number (e.g. 1.5 for 150%), or null.
1715
+ * @returns Scale number (e.g. 1.5 for 150%, -1 for `calc(100% * -1)`), or null.
1613
1716
  */
1614
1717
  function resolveNumberOrPercent(text: string): number | null {
1615
1718
  const trimmed = text.trim()
1616
1719
  const percent = /^(-?\d+(?:\.\d+)?)%$/.exec(trimmed)
1617
- if (percent) return Number(percent[1]) / 100
1720
+ if (percent) return roundTransformValue(Number(percent[1]) / 100)
1618
1721
  const bare = /^-?\d+(?:\.\d+)?$/.exec(trimmed)
1619
- if (bare) return Number(trimmed)
1620
- return null
1722
+ if (bare) return roundTransformValue(Number(trimmed))
1723
+ const evaluated = evaluateLengthExpr(trimmed)
1724
+ if (!evaluated || evaluated.unit === 'rem') return null
1725
+ return roundTransformValue(evaluated.unit === '%' ? evaluated.value / 100 : evaluated.value)
1726
+ }
1727
+
1728
+ /**
1729
+ * Round a composed-transform numeric value to 4 decimals. lightningcss
1730
+ * serializes arbitrary literals (`scale-x-[0.333]`) back as noisy f32 text
1731
+ * (`0.3330000042915344`), and the resolvers `Number()` that verbatim — round
1732
+ * so the RN `transform` array stays clean.
1733
+ * @param value Raw number.
1734
+ * @returns Rounded number.
1735
+ */
1736
+ function roundTransformValue(value: number): number {
1737
+ return Math.round(value * 10_000) / 10_000
1621
1738
  }
1622
1739
 
1623
1740
  /**
@@ -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
  }
@@ -3,25 +3,25 @@ import { dimensionPercentageToNumber } from './length'
3
3
  import type { RNEntry } from './types'
4
4
 
5
5
  /**
6
- * Expand lightningcss's `Display` typed value to an RN `{display}` entry.
7
- * - `keyword` variant (`none`, `flex`, `grid`, `inline`, …) passes through.
8
- * - `pair` variant (the modern CSS model `{inside: {type}, outside,
9
- * isListItem}`) collapses to RN's `'flex'` / `'grid'` when the inside
10
- * type matches, otherwise skips.
6
+ * Display values React Native's `display` style prop actually accepts.
7
+ * Everything else (`block`, `inline`, `inline-block`, `grid`, `table`, …)
8
+ * has no RN analog RN lays out as flex by default, and emitting an invalid
9
+ * value triggers a dev warning + silent drop. So we drop them outright.
10
+ */
11
+ const RN_DISPLAY_VALUES: ReadonlySet<string> = new Set(['none', 'flex', 'contents'])
12
+
13
+ /**
14
+ * Expand lightningcss's `Display` typed value to an RN `{display}` entry,
15
+ * keeping only the values RN supports (`none` / `flex` / `contents`).
16
+ * - `keyword` variant emits only when the keyword is RN-valid.
17
+ * - `pair` variant (the modern CSS model) collapses `flex` inside to
18
+ * `'flex'`; `flow` (`block`/`inline`) and `grid` have no RN analog → drop.
11
19
  * @param value Typed display value.
12
20
  * @returns RN entries (zero or one).
13
21
  */
14
22
  export function displayToEntries(value: Display): readonly RNEntry[] {
15
- if (value.type === 'keyword') return [['display', value.value]]
16
- if (value.type === 'pair') {
17
- const inside = value.inside.type
18
- // `flow` is the default inside mode — maps to `block` / `inline` /
19
- // `inline-block` based on the outer; RN only distinguishes `block`-ish
20
- // from `flex`, so collapse the `flow` family to the `outside` keyword.
21
- if (inside === 'flow') return [['display', value.outside]]
22
- if (inside === 'flex') return [['display', 'flex']]
23
- if (inside === 'grid') return [['display', 'grid']]
24
- }
23
+ if (value.type === 'keyword') return RN_DISPLAY_VALUES.has(value.value) ? [['display', value.value]] : []
24
+ if (value.type === 'pair' && value.inside.type === 'flex') return [['display', 'flex']]
25
25
  return []
26
26
  }
27
27