rnwind 0.0.1 → 0.0.2

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 (330) hide show
  1. package/lib/cjs/core/parser/animation.cjs +427 -0
  2. package/lib/cjs/core/parser/animation.cjs.map +1 -0
  3. package/lib/cjs/core/parser/animation.d.ts +126 -0
  4. package/lib/cjs/core/parser/border-dispatcher.cjs +180 -0
  5. package/lib/cjs/core/parser/border-dispatcher.cjs.map +1 -0
  6. package/lib/cjs/core/parser/border-dispatcher.d.ts +15 -0
  7. package/lib/cjs/core/parser/case-convert.cjs +15 -0
  8. package/lib/cjs/core/parser/case-convert.cjs.map +1 -0
  9. package/lib/cjs/core/parser/case-convert.d.ts +6 -0
  10. package/lib/cjs/core/parser/color-properties-dispatcher.cjs +84 -0
  11. package/lib/cjs/core/parser/color-properties-dispatcher.cjs.map +1 -0
  12. package/lib/cjs/core/parser/color-properties-dispatcher.d.ts +19 -0
  13. package/lib/cjs/core/parser/color.cjs +164 -0
  14. package/lib/cjs/core/parser/color.cjs.map +1 -0
  15. package/lib/cjs/core/parser/color.d.ts +12 -0
  16. package/lib/cjs/core/parser/constants.cjs +21 -0
  17. package/lib/cjs/core/parser/constants.cjs.map +1 -0
  18. package/lib/cjs/core/parser/constants.d.ts +8 -0
  19. package/lib/cjs/core/parser/declaration.cjs +347 -0
  20. package/lib/cjs/core/parser/declaration.cjs.map +1 -0
  21. package/lib/cjs/core/parser/declaration.d.ts +15 -0
  22. package/lib/cjs/core/parser/gradient.cjs +132 -0
  23. package/lib/cjs/core/parser/gradient.cjs.map +1 -0
  24. package/lib/cjs/core/parser/gradient.d.ts +59 -0
  25. package/lib/cjs/core/parser/haptics.cjs +73 -0
  26. package/lib/cjs/core/parser/haptics.cjs.map +1 -0
  27. package/lib/cjs/core/parser/haptics.d.ts +47 -0
  28. package/lib/cjs/core/parser/index.d.ts +8 -0
  29. package/lib/cjs/core/parser/keyframes.cjs +95 -0
  30. package/lib/cjs/core/parser/keyframes.cjs.map +1 -0
  31. package/lib/cjs/core/parser/keyframes.d.ts +26 -0
  32. package/lib/cjs/core/parser/layout-dispatcher.cjs +100 -0
  33. package/lib/cjs/core/parser/layout-dispatcher.cjs.map +1 -0
  34. package/lib/cjs/core/parser/layout-dispatcher.d.ts +14 -0
  35. package/lib/cjs/core/parser/length.cjs +96 -0
  36. package/lib/cjs/core/parser/length.cjs.map +1 -0
  37. package/lib/cjs/core/parser/length.d.ts +48 -0
  38. package/lib/cjs/core/parser/motion-dispatcher.cjs +77 -0
  39. package/lib/cjs/core/parser/motion-dispatcher.cjs.map +1 -0
  40. package/lib/cjs/core/parser/motion-dispatcher.d.ts +11 -0
  41. package/lib/cjs/core/parser/property.cjs +22 -0
  42. package/lib/cjs/core/parser/property.cjs.map +1 -0
  43. package/lib/cjs/core/parser/property.d.ts +8 -0
  44. package/lib/cjs/core/parser/safe-area.cjs +404 -0
  45. package/lib/cjs/core/parser/safe-area.cjs.map +1 -0
  46. package/lib/cjs/core/parser/safe-area.d.ts +39 -0
  47. package/lib/cjs/core/parser/selector.cjs +22 -0
  48. package/lib/cjs/core/parser/selector.cjs.map +1 -0
  49. package/lib/cjs/core/parser/selector.d.ts +11 -0
  50. package/lib/cjs/core/parser/shorthand.cjs +156 -0
  51. package/lib/cjs/core/parser/shorthand.cjs.map +1 -0
  52. package/lib/cjs/core/parser/shorthand.d.ts +61 -0
  53. package/lib/cjs/core/parser/text-truncate.cjs +78 -0
  54. package/lib/cjs/core/parser/text-truncate.cjs.map +1 -0
  55. package/lib/cjs/core/parser/text-truncate.d.ts +44 -0
  56. package/lib/cjs/core/parser/theme-vars.cjs +414 -0
  57. package/lib/cjs/core/parser/theme-vars.cjs.map +1 -0
  58. package/lib/cjs/core/parser/theme-vars.d.ts +61 -0
  59. package/lib/cjs/core/parser/tokens.cjs +304 -0
  60. package/lib/cjs/core/parser/tokens.cjs.map +1 -0
  61. package/lib/cjs/core/parser/tokens.d.ts +45 -0
  62. package/lib/cjs/core/parser/transform.cjs +198 -0
  63. package/lib/cjs/core/parser/transform.cjs.map +1 -0
  64. package/lib/cjs/core/parser/transform.d.ts +36 -0
  65. package/lib/cjs/core/parser/tw-parser.cjs +1567 -0
  66. package/lib/cjs/core/parser/tw-parser.cjs.map +1 -0
  67. package/lib/cjs/core/parser/tw-parser.d.ts +194 -0
  68. package/lib/cjs/core/parser/types.d.ts +37 -0
  69. package/lib/cjs/core/parser/typography-dispatcher.cjs +93 -0
  70. package/lib/cjs/core/parser/typography-dispatcher.cjs.map +1 -0
  71. package/lib/cjs/core/parser/typography-dispatcher.d.ts +11 -0
  72. package/lib/cjs/core/parser/typography.cjs +97 -0
  73. package/lib/cjs/core/parser/typography.cjs.map +1 -0
  74. package/lib/cjs/core/parser/typography.d.ts +43 -0
  75. package/lib/cjs/core/style-builder/build-style.cjs +397 -0
  76. package/lib/cjs/core/style-builder/build-style.cjs.map +1 -0
  77. package/lib/cjs/core/style-builder/build-style.d.ts +54 -0
  78. package/lib/cjs/core/style-builder/index.d.ts +3 -0
  79. package/lib/cjs/core/style-builder/union-builder.cjs +326 -0
  80. package/lib/cjs/core/style-builder/union-builder.cjs.map +1 -0
  81. package/lib/cjs/core/style-builder/union-builder.d.ts +128 -0
  82. package/lib/cjs/core/types.d.ts +14 -0
  83. package/lib/cjs/metro/dts.cjs +127 -0
  84. package/lib/cjs/metro/dts.cjs.map +1 -0
  85. package/lib/cjs/metro/dts.d.ts +16 -0
  86. package/lib/cjs/metro/index.cjs +19 -0
  87. package/lib/cjs/metro/index.cjs.map +1 -0
  88. package/lib/cjs/metro/index.d.ts +9 -0
  89. package/lib/cjs/metro/resolver.cjs +47 -0
  90. package/lib/cjs/metro/resolver.cjs.map +1 -0
  91. package/lib/cjs/metro/resolver.d.ts +22 -0
  92. package/lib/cjs/metro/state.cjs +251 -0
  93. package/lib/cjs/metro/state.cjs.map +1 -0
  94. package/lib/cjs/metro/state.d.ts +72 -0
  95. package/lib/cjs/metro/transform-ast.cjs +1255 -0
  96. package/lib/cjs/metro/transform-ast.cjs.map +1 -0
  97. package/lib/cjs/metro/transform-ast.d.ts +73 -0
  98. package/lib/cjs/metro/transformer.cjs +345 -0
  99. package/lib/cjs/metro/transformer.cjs.map +1 -0
  100. package/lib/cjs/metro/transformer.d.ts +47 -0
  101. package/lib/cjs/metro/warn-unknown-classes.cjs +86 -0
  102. package/lib/cjs/metro/warn-unknown-classes.cjs.map +1 -0
  103. package/lib/cjs/metro/warn-unknown-classes.d.ts +21 -0
  104. package/lib/cjs/metro/with-config.cjs +196 -0
  105. package/lib/cjs/metro/with-config.cjs.map +1 -0
  106. package/lib/cjs/metro/with-config.d.ts +57 -0
  107. package/lib/cjs/runtime/chain-handlers.cjs +37 -0
  108. package/lib/cjs/runtime/chain-handlers.cjs.map +1 -0
  109. package/lib/cjs/runtime/chain-handlers.d.ts +33 -0
  110. package/lib/cjs/runtime/components/rnwind-provider.cjs +98 -0
  111. package/lib/cjs/runtime/components/rnwind-provider.cjs.map +1 -0
  112. package/lib/cjs/runtime/components/rnwind-provider.d.ts +84 -0
  113. package/lib/cjs/runtime/gradient-types.d.ts +58 -0
  114. package/lib/cjs/runtime/haptics.cjs +113 -0
  115. package/lib/cjs/runtime/haptics.cjs.map +1 -0
  116. package/lib/cjs/runtime/haptics.d.ts +48 -0
  117. package/lib/cjs/runtime/hooks/use-css.cjs +21 -0
  118. package/lib/cjs/runtime/hooks/use-css.cjs.map +1 -0
  119. package/lib/cjs/runtime/hooks/use-css.d.ts +11 -0
  120. package/lib/cjs/runtime/hooks/use-interact.cjs +46 -0
  121. package/lib/cjs/runtime/hooks/use-interact.cjs.map +1 -0
  122. package/lib/cjs/runtime/hooks/use-interact.d.ts +42 -0
  123. package/lib/cjs/runtime/hooks/use-scheme.cjs +68 -0
  124. package/lib/cjs/runtime/hooks/use-scheme.cjs.map +1 -0
  125. package/lib/cjs/runtime/hooks/use-scheme.d.ts +34 -0
  126. package/lib/cjs/runtime/index.cjs +45 -0
  127. package/lib/cjs/runtime/index.cjs.map +1 -0
  128. package/lib/cjs/runtime/index.d.ts +27 -0
  129. package/lib/cjs/runtime/interactive-box.cjs +35 -0
  130. package/lib/cjs/runtime/interactive-box.cjs.map +1 -0
  131. package/lib/cjs/runtime/interactive-box.d.ts +40 -0
  132. package/lib/cjs/runtime/lookup-css.cjs +542 -0
  133. package/lib/cjs/runtime/lookup-css.cjs.map +1 -0
  134. package/lib/cjs/runtime/lookup-css.d.ts +164 -0
  135. package/lib/cjs/runtime/types.d.ts +29 -0
  136. package/lib/cjs/testing/index.cjs +367 -0
  137. package/lib/cjs/testing/index.cjs.map +1 -0
  138. package/lib/cjs/testing/index.d.ts +145 -0
  139. package/lib/esm/core/parser/animation.d.ts +126 -0
  140. package/lib/esm/core/parser/animation.mjs +408 -0
  141. package/lib/esm/core/parser/animation.mjs.map +1 -0
  142. package/lib/esm/core/parser/border-dispatcher.d.ts +15 -0
  143. package/lib/esm/core/parser/border-dispatcher.mjs +178 -0
  144. package/lib/esm/core/parser/border-dispatcher.mjs.map +1 -0
  145. package/lib/esm/core/parser/case-convert.d.ts +6 -0
  146. package/lib/esm/core/parser/case-convert.mjs +13 -0
  147. package/lib/esm/core/parser/case-convert.mjs.map +1 -0
  148. package/lib/esm/core/parser/color-properties-dispatcher.d.ts +19 -0
  149. package/lib/esm/core/parser/color-properties-dispatcher.mjs +82 -0
  150. package/lib/esm/core/parser/color-properties-dispatcher.mjs.map +1 -0
  151. package/lib/esm/core/parser/color.d.ts +12 -0
  152. package/lib/esm/core/parser/color.mjs +162 -0
  153. package/lib/esm/core/parser/color.mjs.map +1 -0
  154. package/lib/esm/core/parser/constants.d.ts +8 -0
  155. package/lib/esm/core/parser/constants.mjs +13 -0
  156. package/lib/esm/core/parser/constants.mjs.map +1 -0
  157. package/lib/esm/core/parser/declaration.d.ts +15 -0
  158. package/lib/esm/core/parser/declaration.mjs +345 -0
  159. package/lib/esm/core/parser/declaration.mjs.map +1 -0
  160. package/lib/esm/core/parser/gradient.d.ts +59 -0
  161. package/lib/esm/core/parser/gradient.mjs +130 -0
  162. package/lib/esm/core/parser/gradient.mjs.map +1 -0
  163. package/lib/esm/core/parser/haptics.d.ts +47 -0
  164. package/lib/esm/core/parser/haptics.mjs +71 -0
  165. package/lib/esm/core/parser/haptics.mjs.map +1 -0
  166. package/lib/esm/core/parser/index.d.ts +8 -0
  167. package/lib/esm/core/parser/keyframes.d.ts +26 -0
  168. package/lib/esm/core/parser/keyframes.mjs +91 -0
  169. package/lib/esm/core/parser/keyframes.mjs.map +1 -0
  170. package/lib/esm/core/parser/layout-dispatcher.d.ts +14 -0
  171. package/lib/esm/core/parser/layout-dispatcher.mjs +98 -0
  172. package/lib/esm/core/parser/layout-dispatcher.mjs.map +1 -0
  173. package/lib/esm/core/parser/length.d.ts +48 -0
  174. package/lib/esm/core/parser/length.mjs +90 -0
  175. package/lib/esm/core/parser/length.mjs.map +1 -0
  176. package/lib/esm/core/parser/motion-dispatcher.d.ts +11 -0
  177. package/lib/esm/core/parser/motion-dispatcher.mjs +75 -0
  178. package/lib/esm/core/parser/motion-dispatcher.mjs.map +1 -0
  179. package/lib/esm/core/parser/property.d.ts +8 -0
  180. package/lib/esm/core/parser/property.mjs +20 -0
  181. package/lib/esm/core/parser/property.mjs.map +1 -0
  182. package/lib/esm/core/parser/safe-area.d.ts +39 -0
  183. package/lib/esm/core/parser/safe-area.mjs +402 -0
  184. package/lib/esm/core/parser/safe-area.mjs.map +1 -0
  185. package/lib/esm/core/parser/selector.d.ts +11 -0
  186. package/lib/esm/core/parser/selector.mjs +20 -0
  187. package/lib/esm/core/parser/selector.mjs.map +1 -0
  188. package/lib/esm/core/parser/shorthand.d.ts +61 -0
  189. package/lib/esm/core/parser/shorthand.mjs +148 -0
  190. package/lib/esm/core/parser/shorthand.mjs.map +1 -0
  191. package/lib/esm/core/parser/text-truncate.d.ts +44 -0
  192. package/lib/esm/core/parser/text-truncate.mjs +75 -0
  193. package/lib/esm/core/parser/text-truncate.mjs.map +1 -0
  194. package/lib/esm/core/parser/theme-vars.d.ts +61 -0
  195. package/lib/esm/core/parser/theme-vars.mjs +409 -0
  196. package/lib/esm/core/parser/theme-vars.mjs.map +1 -0
  197. package/lib/esm/core/parser/tokens.d.ts +45 -0
  198. package/lib/esm/core/parser/tokens.mjs +298 -0
  199. package/lib/esm/core/parser/tokens.mjs.map +1 -0
  200. package/lib/esm/core/parser/transform.d.ts +36 -0
  201. package/lib/esm/core/parser/transform.mjs +193 -0
  202. package/lib/esm/core/parser/transform.mjs.map +1 -0
  203. package/lib/esm/core/parser/tw-parser.d.ts +194 -0
  204. package/lib/esm/core/parser/tw-parser.mjs +1565 -0
  205. package/lib/esm/core/parser/tw-parser.mjs.map +1 -0
  206. package/lib/esm/core/parser/types.d.ts +37 -0
  207. package/lib/esm/core/parser/typography-dispatcher.d.ts +11 -0
  208. package/lib/esm/core/parser/typography-dispatcher.mjs +91 -0
  209. package/lib/esm/core/parser/typography-dispatcher.mjs.map +1 -0
  210. package/lib/esm/core/parser/typography.d.ts +43 -0
  211. package/lib/esm/core/parser/typography.mjs +91 -0
  212. package/lib/esm/core/parser/typography.mjs.map +1 -0
  213. package/lib/esm/core/style-builder/build-style.d.ts +54 -0
  214. package/lib/esm/core/style-builder/build-style.mjs +395 -0
  215. package/lib/esm/core/style-builder/build-style.mjs.map +1 -0
  216. package/lib/esm/core/style-builder/index.d.ts +3 -0
  217. package/lib/esm/core/style-builder/union-builder.d.ts +128 -0
  218. package/lib/esm/core/style-builder/union-builder.mjs +324 -0
  219. package/lib/esm/core/style-builder/union-builder.mjs.map +1 -0
  220. package/lib/esm/core/types.d.ts +14 -0
  221. package/lib/esm/metro/dts.d.ts +16 -0
  222. package/lib/esm/metro/dts.mjs +125 -0
  223. package/lib/esm/metro/dts.mjs.map +1 -0
  224. package/lib/esm/metro/index.d.ts +9 -0
  225. package/lib/esm/metro/index.mjs +6 -0
  226. package/lib/esm/metro/index.mjs.map +1 -0
  227. package/lib/esm/metro/resolver.d.ts +22 -0
  228. package/lib/esm/metro/resolver.mjs +43 -0
  229. package/lib/esm/metro/resolver.mjs.map +1 -0
  230. package/lib/esm/metro/state.d.ts +72 -0
  231. package/lib/esm/metro/state.mjs +243 -0
  232. package/lib/esm/metro/state.mjs.map +1 -0
  233. package/lib/esm/metro/transform-ast.d.ts +73 -0
  234. package/lib/esm/metro/transform-ast.mjs +1234 -0
  235. package/lib/esm/metro/transform-ast.mjs.map +1 -0
  236. package/lib/esm/metro/transformer.d.ts +47 -0
  237. package/lib/esm/metro/transformer.mjs +322 -0
  238. package/lib/esm/metro/transformer.mjs.map +1 -0
  239. package/lib/esm/metro/warn-unknown-classes.d.ts +21 -0
  240. package/lib/esm/metro/warn-unknown-classes.mjs +84 -0
  241. package/lib/esm/metro/warn-unknown-classes.mjs.map +1 -0
  242. package/lib/esm/metro/with-config.d.ts +57 -0
  243. package/lib/esm/metro/with-config.mjs +194 -0
  244. package/lib/esm/metro/with-config.mjs.map +1 -0
  245. package/lib/esm/runtime/chain-handlers.d.ts +33 -0
  246. package/lib/esm/runtime/chain-handlers.mjs +34 -0
  247. package/lib/esm/runtime/chain-handlers.mjs.map +1 -0
  248. package/lib/esm/runtime/components/rnwind-provider.d.ts +84 -0
  249. package/lib/esm/runtime/components/rnwind-provider.mjs +94 -0
  250. package/lib/esm/runtime/components/rnwind-provider.mjs.map +1 -0
  251. package/lib/esm/runtime/gradient-types.d.ts +58 -0
  252. package/lib/esm/runtime/haptics.d.ts +48 -0
  253. package/lib/esm/runtime/haptics.mjs +110 -0
  254. package/lib/esm/runtime/haptics.mjs.map +1 -0
  255. package/lib/esm/runtime/hooks/use-css.d.ts +11 -0
  256. package/lib/esm/runtime/hooks/use-css.mjs +19 -0
  257. package/lib/esm/runtime/hooks/use-css.mjs.map +1 -0
  258. package/lib/esm/runtime/hooks/use-interact.d.ts +42 -0
  259. package/lib/esm/runtime/hooks/use-interact.mjs +44 -0
  260. package/lib/esm/runtime/hooks/use-interact.mjs.map +1 -0
  261. package/lib/esm/runtime/hooks/use-scheme.d.ts +34 -0
  262. package/lib/esm/runtime/hooks/use-scheme.mjs +63 -0
  263. package/lib/esm/runtime/hooks/use-scheme.mjs.map +1 -0
  264. package/lib/esm/runtime/index.d.ts +27 -0
  265. package/lib/esm/runtime/index.mjs +18 -0
  266. package/lib/esm/runtime/index.mjs.map +1 -0
  267. package/lib/esm/runtime/interactive-box.d.ts +40 -0
  268. package/lib/esm/runtime/interactive-box.mjs +33 -0
  269. package/lib/esm/runtime/interactive-box.mjs.map +1 -0
  270. package/lib/esm/runtime/lookup-css.d.ts +164 -0
  271. package/lib/esm/runtime/lookup-css.mjs +531 -0
  272. package/lib/esm/runtime/lookup-css.mjs.map +1 -0
  273. package/lib/esm/runtime/types.d.ts +29 -0
  274. package/lib/esm/testing/index.d.ts +145 -0
  275. package/lib/esm/testing/index.mjs +344 -0
  276. package/lib/esm/testing/index.mjs.map +1 -0
  277. package/package.json +79 -13
  278. package/preset.css +1171 -0
  279. package/src/core/parser/animation.ts +404 -0
  280. package/src/core/parser/border-dispatcher.ts +176 -0
  281. package/src/core/parser/case-convert.ts +10 -0
  282. package/src/core/parser/color-properties-dispatcher.ts +78 -0
  283. package/src/core/parser/color.ts +157 -0
  284. package/src/core/parser/constants.ts +11 -0
  285. package/src/core/parser/declaration.ts +340 -0
  286. package/src/core/parser/gradient.ts +148 -0
  287. package/src/core/parser/haptics.ts +88 -0
  288. package/src/core/parser/index.ts +8 -0
  289. package/src/core/parser/keyframes.ts +84 -0
  290. package/src/core/parser/layout-dispatcher.ts +92 -0
  291. package/src/core/parser/length.ts +100 -0
  292. package/src/core/parser/motion-dispatcher.ts +89 -0
  293. package/src/core/parser/property.ts +15 -0
  294. package/src/core/parser/safe-area.ts +404 -0
  295. package/src/core/parser/selector.ts +17 -0
  296. package/src/core/parser/shorthand.ts +152 -0
  297. package/src/core/parser/text-truncate.ts +79 -0
  298. package/src/core/parser/theme-vars.ts +412 -0
  299. package/src/core/parser/tokens.ts +286 -0
  300. package/src/core/parser/transform.ts +195 -0
  301. package/src/core/parser/tw-parser.ts +1709 -0
  302. package/src/core/parser/types.ts +45 -0
  303. package/src/core/parser/typography-dispatcher.ts +83 -0
  304. package/src/core/parser/typography.ts +83 -0
  305. package/src/core/style-builder/build-style.ts +442 -0
  306. package/src/core/style-builder/index.ts +3 -0
  307. package/src/core/style-builder/union-builder.ts +328 -0
  308. package/src/core/types.ts +15 -0
  309. package/src/metro/dts.ts +128 -0
  310. package/src/metro/index.ts +9 -0
  311. package/src/metro/resolver.ts +42 -0
  312. package/src/metro/state.ts +257 -0
  313. package/src/metro/transform-ast.ts +1498 -0
  314. package/src/metro/transformer.ts +347 -0
  315. package/src/metro/warn-unknown-classes.ts +79 -0
  316. package/src/metro/with-config.ts +229 -0
  317. package/src/runtime/chain-handlers.ts +47 -0
  318. package/src/runtime/components/rnwind-provider.tsx +144 -0
  319. package/src/runtime/gradient-types.ts +60 -0
  320. package/src/runtime/haptics.ts +120 -0
  321. package/src/runtime/hooks/use-css.ts +16 -0
  322. package/src/runtime/hooks/use-interact.ts +65 -0
  323. package/src/runtime/hooks/use-scheme.ts +63 -0
  324. package/src/runtime/index.ts +54 -0
  325. package/src/runtime/interactive-box.tsx +57 -0
  326. package/src/runtime/lookup-css.ts +628 -0
  327. package/src/runtime/types.ts +32 -0
  328. package/src/testing/index.ts +507 -0
  329. package/src/types/tailwindcss-node.d.ts +33 -0
  330. package/src/index.ts +0 -1
@@ -0,0 +1,1709 @@
1
+ import { compile } from '@tailwindcss/node'
2
+ import { Scanner, type SourceEntry } from '@tailwindcss/oxide'
3
+ import { formatHex as culoriFormatHex } from 'culori'
4
+ import { Features, transform, type TransformOptions } from 'lightningcss'
5
+ import { declarationToRnEntries } from './declaration'
6
+ import { detectGradientAtom, type GradientAtomInfo } from './gradient'
7
+ import { detectHapticAtom, type HapticRequest } from './haptics'
8
+ import { keyframeSelectorOffset, keyframesName, pickAnimationName } from './keyframes'
9
+ import { serializeInitialValue } from './property'
10
+ import { classNameFromSelector } from './selector'
11
+ import { BASE_SCHEME, compileReadyTheme, extractSchemeAliases, extractThemeVars, type ThemeSchemeTable } from './theme-vars'
12
+ import { serializeTokens } from './tokens'
13
+ import type { RNStyle } from './types'
14
+ import type { Declaration as LcDeclaration, TokenOrValue } from 'lightningcss'
15
+
16
+ /**
17
+ * Inferred compiler type. `@tailwindcss/node` doesn't export its
18
+ * compiler shape as a named type, so we pull it off the `compile()`
19
+ * return to stay resilient to minor upstream shape shifts.
20
+ */
21
+ type TailwindCompiler = Awaited<ReturnType<typeof compile>>
22
+
23
+ /**
24
+ * Default LightningCSS transform options for TailwindParser's visitor.
25
+ * Its taken from official Tailwind source:
26
+ * https://github.com/tailwindlabs/tailwindcss/blob/main/packages/%40tailwindcss-node/src/optimize.ts
27
+ */
28
+ const DEFAULT_TRANSFORM_OPTIONS: Partial<TransformOptions<never>> = {
29
+ drafts: {
30
+ customMedia: true,
31
+ },
32
+ nonStandard: {
33
+ deepSelectorCombinator: true,
34
+ },
35
+ include: Features.Nesting | Features.MediaQueries,
36
+ 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
+ },
45
+ }
46
+ /** Parser configuration — one instance per Metro session, theme CSS fixed. */
47
+ export interface TailwindParserConfig {
48
+ /**
49
+ * Theme CSS passed to `@tailwindcss/node`'s compiler. Typically the
50
+ * user's `global.css`. We append `theme(inline)` to the tailwindcss
51
+ * import so Tailwind resolves every `var(--theme-token)` at compile
52
+ * time — that gives lightningcss fully-typed values (integers / rems /
53
+ * colors) instead of unresolved `var()` references.
54
+ */
55
+ themeCss: string
56
+ /**
57
+ * Glob sources the oxide Scanner walks at project-scan time
58
+ * (`parseProject()`). Typically
59
+ * `[{ base: projectRoot, pattern: '**\/*.{ts,tsx,js,jsx}', negated: false }]`
60
+ * plus negated globs for `node_modules` and the rnwind cache dir.
61
+ *
62
+ * When omitted, the scanner has no sources and `parseProject()`
63
+ * returns an empty result — `parseAtoms()` (per-file, content-driven)
64
+ * still works without sources.
65
+ */
66
+ sources?: readonly SourceEntry[]
67
+ }
68
+
69
+ /** Per-call inputs — Metro hands us file content + filename; we derive the extension. */
70
+ export interface ParseOptions {
71
+ content: string
72
+ extension: string
73
+ }
74
+
75
+ /** One parsed keyframe step — offset plus the RN style at that offset. */
76
+ export interface KeyframeStep {
77
+ offset: string
78
+ style: RNStyle
79
+ }
80
+
81
+ /** One parsed `@keyframes` animation block. */
82
+ export interface KeyframeBlock {
83
+ name: string
84
+ steps: KeyframeStep[]
85
+ }
86
+
87
+ /** Interactive variant ('active' / 'focus') an `active:`/`focus:` atom carries. */
88
+ export type InteractiveStateTag = 'active' | 'focus'
89
+
90
+ /**
91
+ * Per-scheme resolved style for a single utility class. Keys are scheme
92
+ * names declared via `@variant <name>` in the theme CSS (or the synthetic
93
+ * `'base'` scheme for themes without variants). Values are the RN style
94
+ * object under that scheme.
95
+ *
96
+ * The reserved `__state` key is set on `active:` / `focus:` atoms so
97
+ * the build-side style-builder can tag the atom for `precomputeHoist`
98
+ * — which routes interactive atoms into the stated-hoist's 4-state
99
+ * precompute. Standard atoms have no `__state`.
100
+ */
101
+ export type SchemedStyle = Readonly<Record<string, RNStyle>> & {
102
+ /** Interactive-state gate set by `active:` / `focus:` variants. */
103
+ readonly __state?: InteractiveStateTag
104
+ }
105
+
106
+ /** Full result of one `parseAtoms` call. */
107
+ export interface ParsedOutput {
108
+ /** Resolved RN style per utility class, per declared scheme. */
109
+ atoms: Map<string, SchemedStyle>
110
+ /** `@keyframes <name> { ... }` blocks the candidates pulled in. */
111
+ keyframes: Map<string, KeyframeBlock>
112
+ /** `@property --x { initial-value: y }` declared custom-property defaults. */
113
+ propertyDefaults: Map<string, string>
114
+ /**
115
+ * Gradient metadata per atom, for atoms that play a role in a
116
+ * Tailwind v4 gradient (`from-*`, `via-*`, `to-*`, `bg-gradient-to-*`,
117
+ * `bg-linear-to-*`). The transformer reads this map to extract
118
+ * `colors / start / end` props at JSX-rewrite time. Regular non-
119
+ * gradient atoms don't appear here.
120
+ */
121
+ gradientAtoms: Map<string, GradientAtomInfo>
122
+ /**
123
+ * Haptic metadata per atom for classes that emit a
124
+ * `--rnwind-haptic` marker. The transformer reads this map to
125
+ * strip the atom from the className and wire a mount-time or
126
+ * press-time call into the `onHaptics` callback registered on
127
+ * `<SchemeProvider>`.
128
+ */
129
+ hapticAtoms: Map<string, HapticRequest>
130
+ /** Candidates oxide surfaced, in document order. */
131
+ candidates: readonly string[]
132
+ /** Every scheme the theme declares (or `['base']` when there are no `@variant` blocks). */
133
+ schemes: readonly string[]
134
+ /**
135
+ * Responsive breakpoint name → minimum-width threshold (px). Pulled from
136
+ * `--breakpoint-*` tokens in the compiled `:root` block, so both
137
+ * Tailwind defaults (`sm`, `md`, `lg`, `xl`, `2xl`) and any user
138
+ * override (e.g. `--breakpoint-3xl: 120rem`) land here. Used by the
139
+ * style-builder to emit `registerBreakpoints({...})` in the manifest
140
+ * so the runtime can filter `md:*` / `lg:*` atoms based on the
141
+ * provider's `windowWidth`.
142
+ */
143
+ breakpoints: ReadonlyMap<string, number>
144
+ }
145
+
146
+ /**
147
+ * Parses one source file's Tailwind usage into RN-ready style objects.
148
+ *
149
+ * Pipeline:
150
+ * 1. `@tailwindcss/oxide` Scanner finds every Tailwind candidate.
151
+ * 2. `@tailwindcss/node` compiler emits CSS for those candidates with
152
+ * theme tokens inlined.
153
+ * 3. lightningcss `transform` + typed visitor walks the emitted CSS:
154
+ * - `style` rules → per-class RN-style object.
155
+ * - `@keyframes` rules → per-name step map (Reanimated-ready).
156
+ * - `@property` rules → custom-property initial values.
157
+ *
158
+ * One instance holds one Scanner + one lazily-built compiler so repeated
159
+ * calls share upstream state. Theme CSS is fixed at construction — theme
160
+ * edits require a new parser.
161
+ */
162
+ export class TailwindParser {
163
+ private readonly scanner: Scanner
164
+ private compiler: TailwindCompiler | undefined
165
+ private readonly themeSchemes: ThemeSchemeTable
166
+ private readonly schemeAliases: ReadonlyMap<string, string>
167
+ /**
168
+ * Memoise `resolveCandidates` results by candidate-list fingerprint.
169
+ * Fast Refresh hits this on every save: oxide's scan is cheap, but
170
+ * the LightningCSS visitor walk over the compiled CSS is ~2ms per
171
+ * file. A file whose `className` literals didn't change returns the
172
+ * SAME candidate set, so the second `parseAtoms` call returns the
173
+ * cached `ParsedOutput` — zero compile, zero visitor walk.
174
+ *
175
+ * Theme CSS changes build a new `TailwindParser` (from
176
+ * `getRnwindState` detecting the hash shift), so this cache is
177
+ * naturally invalidated — no stale-theme values leak through.
178
+ */
179
+ private readonly parseCache = new Map<string, ParsedOutput>()
180
+
181
+ /**
182
+ * Build a parser bound to a theme CSS source. `@theme` and
183
+ * `@variant` blocks are extracted eagerly into a scheme table the
184
+ * visitor consults when resolving `var(--x)` references.
185
+ * @param config Parser configuration.
186
+ */
187
+ constructor(private readonly config: TailwindParserConfig) {
188
+ this.themeSchemes = extractThemeVars(config.themeCss)
189
+ this.schemeAliases = extractSchemeAliases(config.themeCss)
190
+ this.scanner = new Scanner({ sources: config.sources ? [...config.sources] : [] })
191
+ }
192
+
193
+ /**
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.
199
+ * @returns Scheme names.
200
+ */
201
+ public get declaredSchemes(): readonly string[] {
202
+ const variants = [...this.themeSchemes.keys()].filter((name) => name !== BASE_SCHEME)
203
+ return variants.length > 0 ? variants : [BASE_SCHEME]
204
+ }
205
+
206
+ /**
207
+ * Build an effective var table for one scheme — base vars overridden by
208
+ * variant vars. When the scheme IS `'base'` (no variants declared), the
209
+ * base table is returned unchanged.
210
+ * @param scheme Scheme name.
211
+ * @returns Effective var name → value lookup for the scheme.
212
+ */
213
+ private effectiveVars(scheme: string): ReadonlyMap<string, string> {
214
+ const base = this.themeSchemes.get(BASE_SCHEME)
215
+ const variant = scheme === BASE_SCHEME ? undefined : this.themeSchemes.get(scheme)
216
+ if (!variant) return base ?? new Map()
217
+ // eslint-disable-next-line unicorn/no-useless-collection-argument
218
+ const merged = new Map(base ?? [])
219
+ for (const [k, v] of variant) merged.set(k, v)
220
+ return merged
221
+ }
222
+
223
+ /**
224
+ * Build the Tailwind compiler on first use and cache it. The theme CSS
225
+ * gets a `theme(inline)` modifier on its `@import 'tailwindcss'` so
226
+ * lightningcss sees resolved colors/lengths instead of `var()` refs.
227
+ * @returns Cached compiler instance.
228
+ */
229
+ private async ensureCompiler(): Promise<TailwindCompiler> {
230
+ if (this.compiler) return this.compiler
231
+ const ready = compileReadyTheme(this.config.themeCss, this.themeSchemes)
232
+ try {
233
+ this.compiler = await compile(withInlineTheme(ready), {
234
+ base: process.cwd(),
235
+ onDependency: () => {},
236
+ })
237
+ } catch (error) {
238
+ throw wrapThemeError(error)
239
+ }
240
+ return this.compiler
241
+ }
242
+
243
+ /**
244
+ * Parse one file's Tailwind usage into the full typed result.
245
+ * @param options Source content + extension.
246
+ * @param options.content Raw source text to scan for Tailwind candidates.
247
+ * @param options.extension File extension (`tsx`, `ts`, `jsx`, `js`) — feeds oxide's tokenizer.
248
+ * @returns RN atoms, keyframes, property defaults, candidates list.
249
+ */
250
+ public async parseAtoms({ content, extension }: ParseOptions): Promise<ParsedOutput> {
251
+ const candidates = this.scanner.getCandidatesWithPositions({ content, extension }).map((c) => c.candidate)
252
+ const fingerprint = fingerprintCandidates(candidates)
253
+ const cached = this.parseCache.get(fingerprint)
254
+ if (cached) return cached
255
+ const result = await this.resolveCandidates(candidates)
256
+ this.parseCache.set(fingerprint, result)
257
+ return result
258
+ }
259
+
260
+ /**
261
+ * Scan every source file the Scanner was configured to watch via
262
+ * `sources` and resolve the union of candidates in one pass. Used by
263
+ * `UnionBuilder` at Metro startup (and on first worker access) to
264
+ * populate the complete atom registry before ANY per-file transform
265
+ * has run — so scheme files never ship a partial view of the theme.
266
+ *
267
+ * Hot-reload path uses `parseAtoms` for the per-file delta; this one
268
+ * only runs once per parser instance (and whenever the parser is
269
+ * rebuilt due to a theme CSS change).
270
+ * @returns Full RN atoms, keyframes, property defaults for every
271
+ * candidate discovered across the configured sources.
272
+ */
273
+ public async parseProject(): Promise<ParsedOutput> {
274
+ const candidates = this.scanner.scan()
275
+ return this.resolveCandidates(candidates)
276
+ }
277
+
278
+ /**
279
+ * Compile + typed-visit the given candidate class names. Shared
280
+ * implementation for both `parseAtoms` (single file) and
281
+ * `parseProject` (whole project).
282
+ * @param candidates Class-name candidates the oxide Scanner produced.
283
+ * @returns Fully-typed parser result.
284
+ */
285
+ private async resolveCandidates(candidates: readonly string[]): Promise<ParsedOutput> {
286
+ if (candidates.length === 0) return emptyOutput()
287
+ const compiler = await this.ensureCompiler()
288
+ let css: string
289
+ try {
290
+ css = compiler.build([...candidates])
291
+ } catch (error) {
292
+ throw wrapThemeError(error)
293
+ }
294
+ // `compiler.build(candidates)` memoizes across calls — it returns CSS for
295
+ // every candidate the compiler has EVER seen in this process. To keep
296
+ // parser output pure per-call we restrict outputs to this call's
297
+ // candidates:
298
+ // - atoms: match class selectors against `wanted`.
299
+ // - keyframes: collect `animation-name` references during the style
300
+ // walk, then filter the visited keyframes to referenced names.
301
+ const wanted = new Set(candidates)
302
+ const schemes = this.declaredSchemes
303
+ // Tailwind's compiled CSS contains every theme token — including
304
+ // ones imported from secondary CSS files (e.g. `@import
305
+ // 'rnwind/css'`). Pull them out of the `:root` block so
306
+ // `var(--duration-normal)` style references in unparsed declarations
307
+ // resolve to literal values (`220ms`) instead of being passed through
308
+ // to RN, which can't read CSS custom properties.
309
+ const compiledTheme = extractRootCustomProperties(css)
310
+ const schemeTables = new Map<string, ReadonlyMap<string, string>>()
311
+ for (const scheme of schemes) {
312
+ const merged = new Map(compiledTheme)
313
+ for (const [k, v] of this.effectiveVars(scheme)) merged.set(k, v)
314
+ schemeTables.set(scheme, merged)
315
+ }
316
+
317
+ const atoms = new Map<string, Record<string, RNStyle>>()
318
+ const keyframes: ParsedOutput['keyframes'] = new Map()
319
+ const referencedKeyframes = new Set<string>()
320
+ const propertyDefaults: ParsedOutput['propertyDefaults'] = new Map()
321
+ const gradientAtoms: ParsedOutput['gradientAtoms'] = new Map()
322
+ const hapticAtoms: ParsedOutput['hapticAtoms'] = new Map()
323
+ const breakpoints = new Map<string, number>()
324
+ const { schemeAliases } = this
325
+
326
+ try {
327
+ transform({
328
+ ...DEFAULT_TRANSFORM_OPTIONS,
329
+ filename: 'rnwind-virtual.css',
330
+ code: Buffer.from(css),
331
+ visitor: {
332
+ Rule: {
333
+ style(rule) {
334
+ for (const selector of rule.value.selectors) {
335
+ const className = classNameFromSelector(selector)
336
+ if (!className || !wanted.has(className)) continue
337
+ processStyleRule(
338
+ rule.value.declarations.declarations,
339
+ className,
340
+ { schemes, schemeTables, atoms, referencedKeyframes, schemeAliases, breakpoints },
341
+ rule.value.rules ?? [],
342
+ )
343
+ // Gradient atoms are detected per rule: the parser's main
344
+ // RN-style path drops the `--tw-gradient-*` customs as
345
+ // unsupported, but for gradient utilities we want to
346
+ // surface their role + resolved colour so the transformer
347
+ // can rewrite `<LinearGradient className="...">` into
348
+ // `colors={...}` / `start={...}` / `end={...}` props.
349
+ const gradient = detectGradientAtom(rule.value.declarations.declarations)
350
+ if (gradient) gradientAtoms.set(className, gradient)
351
+ // Haptics may live on the rule directly OR inside a
352
+ // nested pseudo (e.g. `&:active` for `active:haptic-*`).
353
+ // Inspect both so `active:haptic-medium` registers.
354
+ const hapticDecls: LcDeclaration[] = [...rule.value.declarations.declarations]
355
+ for (const nested of rule.value.rules ?? []) hapticDecls.push(...collectNestedDecls(nested))
356
+ const haptic = detectHapticAtom(hapticDecls)
357
+ if (haptic) hapticAtoms.set(className, haptic)
358
+ }
359
+ },
360
+ keyframes(rule) {
361
+ const name = keyframesName(rule.value.name)
362
+ if (!name) return
363
+ const steps: KeyframeStep[] = []
364
+ const baseTable = schemeTables.get(BASE_SCHEME) ?? schemeTables.get(schemes[0] ?? BASE_SCHEME)
365
+ for (const frame of rule.value.keyframes) {
366
+ const offset = keyframeSelectorOffset(frame.selectors)
367
+ if (!offset) continue
368
+ const style: RNStyle = {}
369
+ const frameDecls = frame.declarations.declarations ?? []
370
+ for (const decl of frameDecls) {
371
+ for (const [key, value] of declarationToRnEntries(decl, baseTable)) style[key] = value
372
+ }
373
+ steps.push({ offset, style })
374
+ }
375
+ keyframes.set(name, { name, steps })
376
+ },
377
+ property(rule) {
378
+ const initial = serializeInitialValue(rule.value.initialValue)
379
+ if (initial !== null) propertyDefaults.set(rule.value.name, initial)
380
+ },
381
+ },
382
+ },
383
+ })
384
+ } catch (error) {
385
+ throw wrapThemeError(error)
386
+ }
387
+
388
+ // Prune keyframes to those actually referenced by this call's atoms.
389
+ for (const name of keyframes.keys()) {
390
+ if (!referencedKeyframes.has(name)) keyframes.delete(name)
391
+ }
392
+
393
+ return { atoms, keyframes, propertyDefaults, gradientAtoms, hapticAtoms, candidates: [...candidates], schemes, breakpoints }
394
+ }
395
+ }
396
+
397
+ /**
398
+ * Wrap an error from `@tailwindcss/node`'s compiler or `lightningcss`'s
399
+ * transform with a `rnwind:` prefix so the user sees a clear "this came
400
+ * from your theme CSS" signal in Metro's stack trace. Preserves the
401
+ * original error as `cause` so downstream tooling (Sentry, Metro
402
+ * symbolication) can still inspect it.
403
+ * @param error Underlying error from the compiler or transform.
404
+ * @returns Prefixed Error with the original attached as `cause`.
405
+ */
406
+ function wrapThemeError(error: unknown): Error {
407
+ const message = error instanceof Error ? error.message : String(error)
408
+ const wrapped = new Error(
409
+ `rnwind: failed to compile theme CSS — ${message}\n` +
410
+ `Check your global.css for unbalanced braces, unknown @utility / @variant declarations, ` +
411
+ `or unsupported color functions. Run \`bun run --cwd packages/rnwind code-check\` if this is the rnwind repo itself.`,
412
+ )
413
+ if (error instanceof Error) (wrapped as { cause?: unknown }).cause = error
414
+ return wrapped
415
+ }
416
+
417
+ /**
418
+ * Fingerprint a candidate list for `parseCache` lookup. Sorting gives
419
+ * the same key regardless of source order (the set of candidates is
420
+ * what drives `resolveCandidates`'s output, not their order).
421
+ * @param candidates Raw oxide-scanner output for one file.
422
+ * @returns Canonical string key.
423
+ */
424
+ function fingerprintCandidates(candidates: readonly string[]): string {
425
+ if (candidates.length === 0) return ''
426
+ if (candidates.length === 1) return candidates[0]!
427
+ return [...candidates].toSorted((a, b) => a.localeCompare(b)).join('\0')
428
+ }
429
+
430
+ /**
431
+ * Empty sentinel returned when oxide finds no candidates in the file.
432
+ * @returns Zero-atom result with only the `base` scheme declared.
433
+ */
434
+ function emptyOutput(): ParsedOutput {
435
+ return {
436
+ atoms: new Map(),
437
+ keyframes: new Map(),
438
+ propertyDefaults: new Map(),
439
+ gradientAtoms: new Map(),
440
+ hapticAtoms: new Map(),
441
+ candidates: [],
442
+ schemes: [BASE_SCHEME],
443
+ breakpoints: new Map(),
444
+ }
445
+ }
446
+
447
+ /**
448
+ * Upgrade `@import 'tailwindcss'` (single- or double-quoted, with or
449
+ * without an existing `theme(...)` clause) to `@import 'tailwindcss'
450
+ * theme(inline)`. User-authored `theme(...)` clauses are preserved so
451
+ * overrides win.
452
+ * @param css Theme CSS source.
453
+ * @returns CSS with the Tailwind import upgraded.
454
+ */
455
+ function withInlineTheme(css: string): string {
456
+ return css.replaceAll(/(@import\s+['"]tailwindcss['"])(?!\s*theme\()/g, '$1 theme(inline)')
457
+ }
458
+
459
+ /**
460
+ * Collect rule-local custom-property writes (`--tw-translate-x`,
461
+ * `--tw-scale-x`, `--tw-skew-y`, …). Tailwind v4 uses these as
462
+ * composable transform tokens that `translate: var(--tw-translate-x)
463
+ * var(--tw-translate-y)` then references. Surfacing them as theme vars
464
+ * lets the declaration converter resolve the references as if they
465
+ * were declared in `@theme`.
466
+ * @param decls All declarations from one lightningcss style rule.
467
+ * @returns Map from custom-property name (with leading `--`) to its raw value.
468
+ */
469
+ function collectRuleLocalVars(decls: readonly { property: string; value: unknown }[]): ReadonlyMap<string, string> {
470
+ const out = new Map<string, string>()
471
+ for (const decl of decls) {
472
+ if (decl.property !== 'custom') continue
473
+ const custom = decl.value as { name: { name: string } | string; value?: readonly TokenOrValue[] }
474
+ const rawName = typeof custom.name === 'string' ? custom.name : custom.name.name
475
+ if (!rawName.startsWith('--tw-')) continue
476
+ if (!custom.value) continue
477
+ const text = serializeTokens(custom.value).trim()
478
+ if (text.length > 0) out.set(rawName, text)
479
+ }
480
+ return out
481
+ }
482
+
483
+ interface StyleRuleContext {
484
+ schemes: readonly string[]
485
+ schemeTables: ReadonlyMap<string, ReadonlyMap<string, string>>
486
+ atoms: Map<string, Record<string, RNStyle>>
487
+ referencedKeyframes: Set<string>
488
+ schemeAliases: ReadonlyMap<string, string>
489
+ /**
490
+ * Mutable breakpoint registry — `applyMediaRule` populates this as it
491
+ * walks responsive variant atoms. The driving signal is the `@media
492
+ * (width >= Xrem)` condition on the nested rule. Result lives on
493
+ * {@link ParsedOutput.breakpoints}.
494
+ */
495
+ breakpoints: Map<string, number>
496
+ }
497
+
498
+ /**
499
+ * Process one matched style rule for a given class name: fold its
500
+ * declarations into the per-scheme buckets, detect referenced keyframes,
501
+ * then apply Tailwind's composable transform post-pass.
502
+ * @param declarations Declarations from one lightningcss style rule.
503
+ * @param className Class name this rule's selectors matched.
504
+ * @param ctx Parser-call-wide context (schemes, tables, output maps).
505
+ * @param nestedRules Nested style rules (Tailwind variant prefixes like `dark:`
506
+ * wrap their decls in `&:where(.<scheme>, .<scheme> *)` nested rules).
507
+ */
508
+ function processStyleRule(
509
+ declarations: readonly LcDeclaration[],
510
+ className: string,
511
+ ctx: StyleRuleContext,
512
+ nestedRules: readonly unknown[] = [],
513
+ ): void {
514
+ const bucket = ctx.atoms.get(className) ?? {}
515
+ const ruleLocalVars = collectRuleLocalVars(declarations)
516
+ const ruleSchemeTables = mergeRuleVars(ctx.schemeTables, ruleLocalVars)
517
+ // Detect active:/focus: prefix on the class name. Tailwind emits the
518
+ // actual decls inside `&:active { … }` / `&:focus { … }`, and the
519
+ // OUTER rule has zero direct decls — so the existing per-scheme phase
520
+ // below produces an empty bucket. We collect the inner decls into the
521
+ // base scheme and tag the bucket with `__state` so the style-builder
522
+ // can route the atom into `precomputeHoist`'s state buckets when the
523
+ // interactive state.
524
+ const interactiveState = classNameStateOf(className)
525
+ // Phase 1: outer declarations apply to EVERY scheme (unconditional
526
+ // utilities like `opacity-50`).
527
+ for (const decl of declarations) {
528
+ applyDeclarationToBucket(decl, bucket, ctx.schemes, ruleSchemeTables)
529
+ const animationRef = pickAnimationName(decl)
530
+ if (animationRef) ctx.referencedKeyframes.add(animationRef)
531
+ }
532
+ applyComposedTransform(bucket, ctx.schemes, ruleLocalVars)
533
+ applyComposedShadow(bucket, ctx.schemes, ruleLocalVars)
534
+ applyComposedRing(bucket, ctx.schemes, ruleLocalVars)
535
+ // Phase 2: nested rules — three orthogonal flavours, dispatched on
536
+ // the lightningcss node `type`:
537
+ // - `media`: Tailwind v4 responsive variants (`sm:`, `md:`, …) wrap
538
+ // declarations in `@media (width >= Xrem)`. Decls fold into every
539
+ // scheme; the atom's name carries the breakpoint association so
540
+ // the runtime can gate it on `windowWidth`.
541
+ // - `style` + `interactiveState`: `&:active` / `&:focus` pseudo
542
+ // decls (every scheme — interaction is theme-orthogonal).
543
+ // - `style` (default): Tailwind v4 scheme variants
544
+ // (`&:where(.<scheme>, .<scheme> *)`).
545
+ for (const scheme of ctx.schemes) bucket[scheme] = bucket[scheme] ?? {}
546
+ for (const nested of nestedRules) {
547
+ const node = nested as { type?: string }
548
+ if (node?.type === 'media') {
549
+ applyMediaRule(nested, className, bucket, ctx, ruleSchemeTables, ruleLocalVars)
550
+ continue
551
+ }
552
+ if (interactiveState) {
553
+ applyInteractiveNestedRule(nested, bucket, ctx, ruleSchemeTables, ruleLocalVars)
554
+ } else {
555
+ applyNestedSchemeRule(nested, bucket, ctx, ruleSchemeTables, ruleLocalVars)
556
+ }
557
+ }
558
+ normalizeLineHeightToPx(bucket, ctx.schemes)
559
+ if (interactiveState) (bucket as Record<string, unknown>).__state = interactiveState
560
+ ctx.atoms.set(className, bucket)
561
+ }
562
+
563
+ /** Recognised interactive variant prefixes. RN can drive both at runtime. */
564
+ const INTERACTIVE_PREFIXES = new Set(['active', 'focus'])
565
+
566
+ /**
567
+ * Inspect a class name for a leading interactive variant prefix. The
568
+ * Tailwind compiler emits `active:bg-sky-700` literally (backslash-
569
+ * escaped for CSS), and that name is the same string the parser sees.
570
+ * So a cheap `split(':')` on the head is enough — no need to parse the
571
+ * pseudo-class out of the selector itself.
572
+ * @param className Tailwind utility class (e.g. `active:bg-sky-700`).
573
+ * @returns The interactive state prefix (`'active'` / `'focus'`), or null when none.
574
+ */
575
+ function classNameStateOf(className: string): 'active' | 'focus' | null {
576
+ const colon = className.indexOf(':')
577
+ if (colon === -1) return null
578
+ const prefix = className.slice(0, colon)
579
+ if (!INTERACTIVE_PREFIXES.has(prefix)) return null
580
+ return prefix as 'active' | 'focus'
581
+ }
582
+
583
+ /**
584
+ * Pull the `min-width` threshold (px) out of a `@media (width >= Xrem)`
585
+ * lightningcss node. Tailwind v4 emits exactly this shape for every
586
+ * responsive variant — `>= 40rem` for `sm`, `>= 48rem` for `md`, etc.
587
+ * Returns null for any other media condition (`hover`, `prefers-*`,
588
+ * range bounds we don't model) so the caller can skip non-responsive
589
+ * media wrappers without breaking.
590
+ * @param nested A lightningcss `media` rule node.
591
+ * @returns Threshold in px, or null when the condition isn't a simple
592
+ * width-min check.
593
+ */
594
+ function readMediaMinWidthPx(nested: unknown): number | null {
595
+ if (typeof nested !== 'object' || nested === null) return null
596
+ const node = nested as { type?: string; value?: { query?: { mediaQueries?: readonly unknown[] } } }
597
+ if (node.type !== 'media') return null
598
+ const queries = node.value?.query?.mediaQueries
599
+ if (queries?.length !== 1) return null
600
+ const query = queries[0] as {
601
+ condition?: {
602
+ type?: string
603
+ value?: {
604
+ type?: string
605
+ name?: string
606
+ operator?: string
607
+ value?: { type?: string; value?: { type?: string; value?: { unit?: string; value?: number } } }
608
+ }
609
+ }
610
+ }
611
+ const {condition} = query
612
+ if (condition?.type !== 'feature') return null
613
+ const feature = condition.value
614
+ if (feature?.type !== 'range' || feature.name !== 'width') return null
615
+ if (feature.operator !== 'greater-than-equal') return null
616
+ const length = feature.value
617
+ if (length?.type !== 'length') return null
618
+ const inner = length.value?.value
619
+ if (!inner || typeof inner.value !== 'number') return null
620
+ if (inner.unit === 'rem') return inner.value * 16
621
+ if (inner.unit === 'px') return inner.value
622
+ return null
623
+ }
624
+
625
+ /**
626
+ * Pull the leading `prefix:` segment off a className. `md:bg-red-500`
627
+ * → `'md'`. Returns null for atoms without a colon (the common case)
628
+ * or for empty prefixes.
629
+ * @param className Atom name.
630
+ * @returns Prefix or null.
631
+ */
632
+ function leadingPrefix(className: string): string | null {
633
+ const colon = className.indexOf(':')
634
+ if (colon <= 0) return null
635
+ return className.slice(0, colon)
636
+ }
637
+
638
+ /**
639
+ * Fold a Tailwind v4 responsive `@media (width >= Xrem)` nested rule
640
+ * into every scheme's bucket and record the breakpoint threshold
641
+ * (`md` → 768) on the parser-call context. The runtime later gates the
642
+ * atom on `windowWidth` against this threshold via the prefix on the
643
+ * atom's class name.
644
+ *
645
+ * Read directly from the media condition rather than from compiled
646
+ * `--breakpoint-*` `:root` tokens because Tailwind's `theme(inline)`
647
+ * mode strips those — the only authoritative source for the actual
648
+ * thresholds Tailwind generated is the `@media` query itself.
649
+ * @param nested One nested `media` node from `rule.value.rules`.
650
+ * @param className Outer rule's class name (carries the breakpoint prefix).
651
+ * @param bucket Per-scheme style map for the atom.
652
+ * @param ctx Parser-call-wide context.
653
+ * @param ruleSchemeTables Per-scheme var tables (outer rule's merged table).
654
+ * @param ruleLocalVars Outer rule's `--tw-*` vars (inherited for inner decls).
655
+ */
656
+ function applyMediaRule(
657
+ nested: unknown,
658
+ className: string,
659
+ bucket: Record<string, RNStyle>,
660
+ ctx: StyleRuleContext,
661
+ ruleSchemeTables: ReadonlyMap<string, ReadonlyMap<string, string>>,
662
+ ruleLocalVars: ReadonlyMap<string, string>,
663
+ ): void {
664
+ const decls = collectNestedDecls(nested)
665
+ if (decls.length === 0) return
666
+ const minWidth = readMediaMinWidthPx(nested)
667
+ const prefix = leadingPrefix(className)
668
+ if (minWidth !== null && prefix !== null) ctx.breakpoints.set(prefix, minWidth)
669
+ for (const scheme of ctx.schemes) {
670
+ const table = ruleSchemeTables.get(scheme)
671
+ const schemeBucket = bucket[scheme] ?? {}
672
+ for (const decl of decls) {
673
+ for (const [key, value] of declarationToRnEntries(decl, table)) schemeBucket[key] = value
674
+ const animationRef = pickAnimationName(decl)
675
+ if (animationRef) ctx.referencedKeyframes.add(animationRef)
676
+ }
677
+ const nestedLocalVars = new Map(ruleLocalVars)
678
+ for (const [k, v] of collectRuleLocalVars(decls)) nestedLocalVars.set(k, v)
679
+ applyComposedTransformToScheme(schemeBucket, nestedLocalVars)
680
+ applyComposedShadowToScheme(schemeBucket, nestedLocalVars)
681
+ bucket[scheme] = schemeBucket
682
+ }
683
+ }
684
+
685
+ /**
686
+ * Fold one nested rule from an interactive (`active:` / `focus:`) atom
687
+ * into every scheme's bucket. The Tailwind output for `active:bg-sky-700`
688
+ * is `.active\:bg-sky-700 { &:active { background-color: oklch(...) } }`
689
+ * — the outer rule has zero
690
+ * decls; the leaf lives three levels deep through pseudo + media. We
691
+ * unwrap the pseudo and the media shell, take the inner decls, and
692
+ * apply them across every scheme — interactivity is orthogonal to
693
+ * theme. The bucket gets `__state: 'active' | 'focus'` set elsewhere.
694
+ * @param nested One nested-rule node from `rule.value.rules`.
695
+ * @param bucket Per-scheme style map for the atom.
696
+ * @param ctx Parser-call-wide context.
697
+ * @param ruleSchemeTables Per-scheme var tables (outer rule's merged table).
698
+ * @param ruleLocalVars Outer rule's `--tw-*` vars (inherited for inner decls).
699
+ */
700
+ function applyInteractiveNestedRule(
701
+ nested: unknown,
702
+ bucket: Record<string, RNStyle>,
703
+ ctx: StyleRuleContext,
704
+ ruleSchemeTables: ReadonlyMap<string, ReadonlyMap<string, string>>,
705
+ ruleLocalVars: ReadonlyMap<string, string>,
706
+ ): void {
707
+ const decls = collectNestedDecls(nested)
708
+ if (decls.length === 0) return
709
+ for (const scheme of ctx.schemes) {
710
+ const table = ruleSchemeTables.get(scheme)
711
+ const schemeBucket = bucket[scheme] ?? {}
712
+ for (const decl of decls) {
713
+ for (const [key, value] of declarationToRnEntries(decl, table)) schemeBucket[key] = value
714
+ const animationRef = pickAnimationName(decl)
715
+ if (animationRef) ctx.referencedKeyframes.add(animationRef)
716
+ }
717
+ const nestedLocalVars = new Map(ruleLocalVars)
718
+ for (const [k, v] of collectRuleLocalVars(decls)) nestedLocalVars.set(k, v)
719
+ applyComposedTransformToScheme(schemeBucket, nestedLocalVars)
720
+ applyComposedShadowToScheme(schemeBucket, nestedLocalVars)
721
+ bucket[scheme] = schemeBucket
722
+ }
723
+ }
724
+
725
+ /**
726
+ * Recursively flatten a nested style/media rule into its leaf
727
+ * declarations. Tailwind wraps interactive pseudo decls in `style`
728
+ * nodes (`&:active`, `&:focus`); the walk unwraps them and any
729
+ * `@media` shell around them.
730
+ * @param nested A nested rule node.
731
+ * @returns Every declaration found in the nested subtree.
732
+ */
733
+ function collectNestedDecls(nested: unknown): readonly LcDeclaration[] {
734
+ if (typeof nested !== 'object' || nested === null) return []
735
+ const node = nested as { type?: string; value?: NestedStyleRule & { rules?: readonly unknown[] } }
736
+ if (!node.value) return []
737
+ // `style` (with `&:active`) and `media` wrappers both surface the
738
+ // actual decls. lightningcss surfaces inner-only declarations as a
739
+ // `nested-declarations` node — flatten that too.
740
+ if (node.type === 'nested-declarations') return [...(node.value.declarations?.declarations ?? [])]
741
+ if (node.type !== 'style' && node.type !== 'media') return []
742
+ const out: LcDeclaration[] = [...(node.value.declarations?.declarations ?? [])]
743
+ for (const child of node.value.rules ?? []) out.push(...collectNestedDecls(child))
744
+ return out
745
+ }
746
+
747
+ /**
748
+ * Fold one nested style rule (Tailwind's `&:where(.<scheme>, .<scheme> *)`
749
+ * pattern) into the scheme bucket its selector targets. Rules we can't
750
+ * attribute to a single scheme are skipped — they'd only ever reach the
751
+ * bucket via CSS cascading in a browser, which doesn't translate to RN.
752
+ * @param nested One nested-rule node from `rule.value.rules`.
753
+ * @param bucket Per-scheme style map for the atom.
754
+ * @param ctx Parser-call-wide context.
755
+ * @param ruleSchemeTables Per-scheme var tables (outer rule's merged table).
756
+ * @param ruleLocalVars Outer rule's `--tw-*` vars (inherited for inner decls).
757
+ */
758
+ function applyNestedSchemeRule(
759
+ nested: unknown,
760
+ bucket: Record<string, RNStyle>,
761
+ ctx: StyleRuleContext,
762
+ ruleSchemeTables: ReadonlyMap<string, ReadonlyMap<string, string>>,
763
+ ruleLocalVars: ReadonlyMap<string, string>,
764
+ ): void {
765
+ if (typeof nested !== 'object' || nested === null) return
766
+ const node = nested as { type?: string; value?: NestedStyleRule }
767
+ if (node.type !== 'style' || !node.value) return
768
+ const targetScheme = detectNestedScheme(node.value.selectors, ctx.schemes, ctx.schemeAliases)
769
+ if (!targetScheme) return
770
+ const innerDecls = node.value.declarations?.declarations ?? []
771
+ const table = ruleSchemeTables.get(targetScheme)
772
+ const schemeBucket = bucket[targetScheme] ?? {}
773
+ for (const decl of innerDecls) {
774
+ for (const [key, value] of declarationToRnEntries(decl, table)) schemeBucket[key] = value
775
+ const animationRef = pickAnimationName(decl)
776
+ if (animationRef) ctx.referencedKeyframes.add(animationRef)
777
+ }
778
+ // Apply the composed-transform + shadow post-passes to just this one
779
+ // scheme, so nested `--tw-*` custom prop writes compose correctly.
780
+ const nestedLocalVars = new Map(ruleLocalVars)
781
+ for (const [k, v] of collectRuleLocalVars(innerDecls)) nestedLocalVars.set(k, v)
782
+ applyComposedTransformToScheme(schemeBucket, nestedLocalVars)
783
+ applyComposedShadowToScheme(schemeBucket, nestedLocalVars)
784
+ bucket[targetScheme] = schemeBucket
785
+ }
786
+
787
+ /**
788
+ * Shape of a lightningcss style rule as it appears inside `rule.value.rules`.
789
+ * Loose typing because the full type is a sprawling discriminated union
790
+ * we only need one shape from.
791
+ */
792
+ interface NestedStyleRule {
793
+ selectors: readonly (readonly unknown[])[]
794
+ declarations?: { declarations: readonly LcDeclaration[] }
795
+ }
796
+
797
+ /**
798
+ * Detect which scheme a nested `&:where(.<scheme>, .<scheme> *)`
799
+ * selector targets. Recognises both the rnwind-default literal class
800
+ * (`.dark`) and any user-declared `@custom-variant` selector class
801
+ * (`.scheme-dark`), via the `aliases` map built from the theme CSS.
802
+ * @param selectors Nested rule's selector lists.
803
+ * @param schemes Declared scheme names.
804
+ * @param aliases Class-name → scheme-name map from `@custom-variant` decls.
805
+ * @returns Matching scheme name, or null when the selector isn't scheme-scoped.
806
+ */
807
+ function detectNestedScheme(
808
+ selectors: readonly (readonly unknown[])[],
809
+ schemes: readonly string[],
810
+ aliases: ReadonlyMap<string, string>,
811
+ ): string | null {
812
+ const known = new Set(schemes)
813
+ for (const selector of selectors) {
814
+ const found = findSchemeInSelector(selector, schemes, known, aliases)
815
+ if (found) return found
816
+ }
817
+ return null
818
+ }
819
+
820
+ /**
821
+ * Inner half of {@link detectNestedScheme} — extracted so each function
822
+ * stays under the cognitive-complexity cap.
823
+ * @param selector One compound selector (sequence of simple parts).
824
+ * @param schemes Declared scheme names (for recursion).
825
+ * @param known Set form of `schemes` for O(1) lookups.
826
+ * @param aliases Class-name → scheme-name map from `@custom-variant` decls.
827
+ * @returns Matching scheme name, or null.
828
+ */
829
+ function findSchemeInSelector(
830
+ selector: readonly unknown[],
831
+ schemes: readonly string[],
832
+ known: ReadonlySet<string>,
833
+ aliases: ReadonlyMap<string, string>,
834
+ ): string | null {
835
+ for (const part of selector) {
836
+ const direct = matchSchemeClass(part, known, aliases)
837
+ if (direct) return direct
838
+ const nested = matchSchemeInWhere(part, schemes, aliases)
839
+ if (nested) return nested
840
+ }
841
+ return null
842
+ }
843
+
844
+ /**
845
+ * Match a `.scheme` class part against the declared schemes (literal
846
+ * match) or against the `@custom-variant` alias map (e.g. `.scheme-dark`
847
+ * → `dark`).
848
+ * @param part One simple selector part.
849
+ * @param known Declared scheme names.
850
+ * @param aliases Class-name → scheme-name map from `@custom-variant` decls.
851
+ * @returns Matching scheme name, or null.
852
+ */
853
+ function matchSchemeClass(part: unknown, known: ReadonlySet<string>, aliases: ReadonlyMap<string, string>): string | null {
854
+ if (typeof part !== 'object' || part === null) return null
855
+ const node = part as { type?: string; name?: string }
856
+ if (node.type !== 'class') return null
857
+ if (typeof node.name !== 'string') return null
858
+ if (known.has(node.name)) return node.name
859
+ return aliases.get(node.name) ?? null
860
+ }
861
+
862
+ /**
863
+ * Match a `:where(.scheme, …)` pseudo-class wrapper and recurse into
864
+ * its inner selectors.
865
+ * @param part One simple selector part.
866
+ * @param schemes Declared scheme names.
867
+ * @param aliases Class-name → scheme-name map from `@custom-variant` decls.
868
+ * @returns Matching scheme name from inside the `where`, or null.
869
+ */
870
+ function matchSchemeInWhere(part: unknown, schemes: readonly string[], aliases: ReadonlyMap<string, string>): string | null {
871
+ if (typeof part !== 'object' || part === null) return null
872
+ const node = part as { type?: string; kind?: string; selectors?: readonly (readonly unknown[])[] }
873
+ if (node.type !== 'pseudo-class' || node.kind !== 'where' || !node.selectors) return null
874
+ return detectNestedScheme(node.selectors, schemes, aliases)
875
+ }
876
+
877
+ /**
878
+ * Per-scheme version of `applyComposedTransform` — synthesize a
879
+ * `transform` array into a SINGLE scheme's style from its rule-local
880
+ * `--tw-*` vars.
881
+ * @param style Scheme-specific style map.
882
+ * @param ruleLocalVars Combined outer+nested `--tw-*` vars.
883
+ */
884
+ function applyComposedTransformToScheme(style: RNStyle, ruleLocalVars: ReadonlyMap<string, string>): void {
885
+ const composed = composeTransformFromVars(ruleLocalVars)
886
+ if (composed.length === 0) return
887
+ delete style.translate
888
+ delete style.scale
889
+ delete style.rotate
890
+ style.transform = composed
891
+ }
892
+
893
+ /**
894
+ * Per-scheme version of `applyComposedShadow` — synthesize RN shadow
895
+ * longhands into a SINGLE scheme's style from its `--tw-shadow` custom
896
+ * prop.
897
+ * @param style Scheme-specific style map.
898
+ * @param ruleLocalVars Combined outer+nested `--tw-*` vars.
899
+ */
900
+ function applyComposedShadowToScheme(style: RNStyle, ruleLocalVars: ReadonlyMap<string, string>): void {
901
+ const rawShadow = ruleLocalVars.get('--tw-shadow')
902
+ const rawShadowColor = ruleLocalVars.get('--tw-shadow-color')
903
+ if (!rawShadow && rawShadowColor) {
904
+ const color = resolveCustomColorString(rawShadowColor)
905
+ if (!color) return
906
+ delete style.boxShadow
907
+ style.shadowColor = color
908
+ return
909
+ }
910
+ if (!rawShadow) return
911
+ const shadow = parseFirstShadow(rawShadow)
912
+ if (!shadow) return
913
+ delete style.boxShadow
914
+ style.shadowColor = shadow.color
915
+ style.shadowOffset = { width: shadow.x, height: shadow.y }
916
+ style.shadowOpacity = shadow.opacity
917
+ style.shadowRadius = shadow.blur
918
+ style.elevation = Math.max(1, Math.min(24, Math.round(Math.max(shadow.y, shadow.blur / 2))))
919
+ }
920
+
921
+ /**
922
+ * Tailwind v4's `shadow-*` utilities write a `--tw-shadow` custom prop
923
+ * holding the actual `<x> <y> <blur> <spread> <color>` shadow values,
924
+ * then a `box-shadow: var(--tw-shadow)` declaration RN can't use. This
925
+ * post-pass parses `--tw-shadow` and emits the RN shadow longhands —
926
+ * `shadowColor` / `shadowOffset` / `shadowOpacity` / `shadowRadius` /
927
+ * `elevation` — so iOS and Android both render the shadow.
928
+ * @param bucket Per-scheme style map for the atom.
929
+ * @param schemes Scheme names active for this parse.
930
+ * @param ruleLocalVars Rule-local `--tw-*` vars.
931
+ */
932
+ function applyComposedShadow(
933
+ bucket: Record<string, RNStyle>,
934
+ schemes: readonly string[],
935
+ ruleLocalVars: ReadonlyMap<string, string>,
936
+ ): void {
937
+ const rawShadow = ruleLocalVars.get('--tw-shadow')
938
+ const rawShadowColor = ruleLocalVars.get('--tw-shadow-color')
939
+ // Color-only utility (`shadow-red-50`, `shadow-gray-200`, …): emit
940
+ // `shadowColor` + `shadowOpacity: 1` so the explicit color overrides
941
+ // the size utility's 0.1 alpha fallback (matches Tailwind v4 web,
942
+ // where setting `--tw-shadow-color` swaps in a solid color). Offset /
943
+ // blur / elevation come from the partner size utility's atom.
944
+ if (!rawShadow && rawShadowColor) {
945
+ const color = resolveCustomColorString(rawShadowColor)
946
+ if (!color) return
947
+ for (const scheme of schemes) {
948
+ const style = bucket[scheme] ?? {}
949
+ delete style.boxShadow
950
+ style.shadowColor = color
951
+ bucket[scheme] = style
952
+ }
953
+ return
954
+ }
955
+ if (!rawShadow) return
956
+ const shadow = parseFirstShadow(rawShadow)
957
+ if (!shadow) return
958
+ for (const scheme of schemes) {
959
+ const style = bucket[scheme] ?? {}
960
+ delete style.boxShadow
961
+ style.shadowColor = shadow.color
962
+ style.shadowOffset = { width: shadow.x, height: shadow.y }
963
+ style.shadowOpacity = shadow.opacity
964
+ style.shadowRadius = shadow.blur
965
+ style.elevation = Math.max(1, Math.min(24, Math.round(Math.max(shadow.y, shadow.blur / 2))))
966
+ bucket[scheme] = style
967
+ }
968
+ }
969
+
970
+ /**
971
+ * Synthesize RN ring styles from Tailwind's `--tw-ring-color` /
972
+ * `--tw-ring-shadow` composable custom props. RN has no native ring;
973
+ * we approximate by writing `borderColor` + `borderWidth` so the
974
+ * outline is visible. Atoms that ALREADY set borderColor (e.g. paired
975
+ * with `border-2`) keep their value — the ring just won't override.
976
+ * @param bucket Per-scheme style map for the atom.
977
+ * @param schemes Scheme names active for this parse.
978
+ * @param ruleLocalVars Rule-local `--tw-*` vars.
979
+ */
980
+ function applyComposedRing(
981
+ bucket: Record<string, RNStyle>,
982
+ schemes: readonly string[],
983
+ ruleLocalVars: ReadonlyMap<string, string>,
984
+ ): void {
985
+ const ringColor = ruleLocalVars.get('--tw-ring-color')
986
+ if (!ringColor) return
987
+ const color = resolveCustomColorString(ringColor)
988
+ if (!color) return
989
+ for (const scheme of schemes) {
990
+ const style = bucket[scheme] ?? {}
991
+ if (!('borderColor' in style)) style.borderColor = color
992
+ bucket[scheme] = style
993
+ }
994
+ }
995
+
996
+ /**
997
+ * Resolve a CSS color string (`oklch(0.971 0.013 17.38)`, `#ff0000`,
998
+ * `rgb(0 0 0 / 0.1)`) to the hex string RN's `shadowColor` accepts.
999
+ * Wraps culori's parser via {@link parseCssColorToHex}.
1000
+ * @param raw Raw color text from a `--tw-shadow-color` custom prop.
1001
+ * @returns `#rrggbb` string, or null when culori can't parse it.
1002
+ */
1003
+ function resolveCustomColorString(raw: string): string | null {
1004
+ const text = unwrapVariableFallback(raw).trim()
1005
+ if (text.length === 0) return null
1006
+ if (text.startsWith('#')) return text
1007
+ return parseCssColorToHex(text)
1008
+ }
1009
+
1010
+ /**
1011
+ * Parse any CSS color expression into an `#rrggbb` string via culori.
1012
+ * Falls back to null when culori doesn't recognize the format.
1013
+ * @param text CSS color value.
1014
+ * @returns Hex string, or null.
1015
+ */
1016
+ function parseCssColorToHex(text: string): string | null {
1017
+ return formatHexSafe(text)
1018
+ }
1019
+
1020
+ /**
1021
+ * Format a CSS color via culori.
1022
+ * @param text CSS color value.
1023
+ * @returns `#rrggbb` string when culori succeeds, else null.
1024
+ */
1025
+ function formatHexSafe(text: string): string | null {
1026
+ try {
1027
+ const hex = culoriFormatHex(text)
1028
+ return typeof hex === 'string' ? hex : null
1029
+ } catch {
1030
+ return null
1031
+ }
1032
+ }
1033
+
1034
+ interface ParsedShadow {
1035
+ x: number
1036
+ y: number
1037
+ blur: number
1038
+ spread: number
1039
+ color: string
1040
+ opacity: number
1041
+ }
1042
+
1043
+ /**
1044
+ * Parse the first shadow from a `--tw-shadow` custom-property value.
1045
+ * The value is a comma-separated list of shadows; each shadow is
1046
+ * `<x> <y> <blur> <spread> <color>`. RN renders only one shadow per
1047
+ * view, so we keep the first.
1048
+ * @param raw Raw `--tw-shadow` text (post-substitution).
1049
+ * @returns Parsed shadow, or null when the shape is unrecognized.
1050
+ */
1051
+ function parseFirstShadow(raw: string): ParsedShadow | null {
1052
+ // Split on top-level commas (parens-aware) so colors like `rgba(0,0,0,0.5)`
1053
+ // don't fragment the list.
1054
+ const head = topLevelSplit(raw, ',')[0]?.trim()
1055
+ if (!head) return null
1056
+ const { lengths, remainder } = extractShadowLengths(head)
1057
+ const [x = 0, y = 0, blur = 0, spread = 0] = lengths
1058
+ const { color, opacity } = parseShadowColor(remainder.trim())
1059
+ return { x, y, blur, spread, color, opacity }
1060
+ }
1061
+
1062
+ /**
1063
+ * Pull the first 4 numeric tokens out of a shadow expression and return
1064
+ * them alongside the remaining text (which is the color expression).
1065
+ * Shadow shape: `<x> <y> <blur> <spread> <color>` — tokens may be bare
1066
+ * (`0`), px-dimensioned (`1px`), or rem/em/%.
1067
+ * @param head Single shadow expression (one comma-separated entry).
1068
+ * @returns Pixel lengths + the remainder text (color expression).
1069
+ */
1070
+ function extractShadowLengths(head: string): { lengths: number[]; remainder: string } {
1071
+ const lengthRegex = /(?<![A-Za-z(])-?\d*\.?\d+(?:px|rem|em|%)?/g
1072
+ 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)}`
1083
+ }
1084
+ return { lengths, remainder }
1085
+ }
1086
+
1087
+ /**
1088
+ * Coerce one shadow length token into a pixel number. Accepts bare
1089
+ * integers (`0`), `Npx`, and `Nrem` — every shape Tailwind's
1090
+ * `--tw-shadow` value uses.
1091
+ * @param token Token text.
1092
+ * @returns Pixel number.
1093
+ */
1094
+ function parseLengthToken(token: string): number {
1095
+ if (token.endsWith('rem')) return Number(token.slice(0, -3)) * 16
1096
+ if (token.endsWith('em')) return Number(token.slice(0, -2)) * 16
1097
+ if (token.endsWith('px')) return Number(token.slice(0, -2))
1098
+ if (token.endsWith('%')) return Number(token.slice(0, -1))
1099
+ return Number(token)
1100
+ }
1101
+
1102
+ /**
1103
+ * Extract a color string + extracted alpha from a shadow's color
1104
+ * expression. Supports `rgb(...)` / `rgba(...)` / `#rrggbb` / hex with
1105
+ * alpha / theme-resolved CSS color strings.
1106
+ * @param expr Color expression text.
1107
+ * @returns Color string for `shadowColor` + alpha for `shadowOpacity`.
1108
+ */
1109
+ function parseShadowColor(expr: string): { color: string; opacity: number } {
1110
+ const working = unwrapVariableFallback(expr).trim()
1111
+ if (working.length === 0) return { color: '#000', opacity: 0.1 }
1112
+ const rgba = parseRgbaExpression(working)
1113
+ if (rgba) return rgba
1114
+ if (working.startsWith('#')) return { color: working, opacity: 1 }
1115
+ return { color: '#000', opacity: 0.1 }
1116
+ }
1117
+
1118
+ /**
1119
+ * Strip the `var(--name, fallback)` wrapper from a CSS value. Tailwind
1120
+ * wraps shadow colors as `var(--tw-shadow-color, rgb(0 0 0 / 0.1))`,
1121
+ * and when the var is unresolved we want the fallback.
1122
+ * @param expr Raw CSS value.
1123
+ * @returns Inner fallback when wrapped, otherwise the input unchanged.
1124
+ */
1125
+ function unwrapVariableFallback(expr: string): string {
1126
+ const trimmed = expr.trim()
1127
+ if (!trimmed.startsWith('var(') || !trimmed.endsWith(')')) return trimmed
1128
+ const inner = trimmed.slice(4, -1)
1129
+ let depth = 0
1130
+ for (let index = 0; index < inner.length; index += 1) {
1131
+ const ch = inner[index]
1132
+ if (ch === '(') depth += 1
1133
+ else if (ch === ')') depth -= 1
1134
+ else if (ch === ',' && depth === 0) return inner.slice(index + 1)
1135
+ }
1136
+ return trimmed
1137
+ }
1138
+
1139
+ /**
1140
+ * Parse an `rgb(r g b)` / `rgba(r,g,b,a)` / `rgb(r g b / a)` color
1141
+ * expression into a hex + alpha pair. Returns `null` when the shape
1142
+ * doesn't match.
1143
+ * @param text Expression text (already trimmed and unwrapped).
1144
+ * @returns Hex color + alpha, or null.
1145
+ */
1146
+ function parseRgbaExpression(text: string): { color: string; opacity: number } | null {
1147
+ const head = /^rgba?\(([^)]+)\)$/i.exec(text)
1148
+ if (!head) return null
1149
+ const inner = head[1]!.replaceAll(',', ' ').replaceAll('/', ' ')
1150
+ const tokens = inner.split(/\s+/).filter((part) => part.length > 0)
1151
+ if (tokens.length < 3) return null
1152
+ const [r, g, b, alphaText] = tokens
1153
+ let opacity = 1
1154
+ if (typeof alphaText === 'string') {
1155
+ opacity = alphaText.endsWith('%') ? Number(alphaText.slice(0, -1)) / 100 : Number(alphaText)
1156
+ }
1157
+ const hex = `#${[r!, g!, b!]
1158
+ .map((n) =>
1159
+ Math.max(0, Math.min(255, Math.round(Number(n))))
1160
+ .toString(16)
1161
+ .padStart(2, '0'),
1162
+ )
1163
+ .join('')}`
1164
+ return { color: hex, opacity }
1165
+ }
1166
+
1167
+ /**
1168
+ * Split `text` at top-level occurrences of `delimiter`, treating
1169
+ * parentheses as nesting. Used to safely split shadow lists without
1170
+ * fragmenting `rgb(0, 0, 0, 0.5)` on its commas.
1171
+ * @param text Source text.
1172
+ * @param delimiter Single-character delimiter to split on.
1173
+ * @returns Parts of the text between top-level delimiters.
1174
+ */
1175
+ function topLevelSplit(text: string, delimiter: string): string[] {
1176
+ const parts: string[] = []
1177
+ let depth = 0
1178
+ let start = 0
1179
+ for (let index = 0; index < text.length; index += 1) {
1180
+ const ch = text[index]
1181
+ if (ch === '(') depth += 1
1182
+ else if (ch === ')') depth -= 1
1183
+ else if (ch === delimiter && depth === 0) {
1184
+ parts.push(text.slice(start, index))
1185
+ start = index + 1
1186
+ }
1187
+ }
1188
+ parts.push(text.slice(start))
1189
+ return parts
1190
+ }
1191
+
1192
+ /**
1193
+ * Tailwind v4's `text-*` utilities emit `line-height` as a unitless
1194
+ * multiplier (`calc(2.5 / 2.25)` for `text-4xl`) that the browser
1195
+ * resolves against the element's `font-size`. RN's `lineHeight` is
1196
+ * always pixels — so when both `fontSize` and a multiplier-shaped
1197
+ * `lineHeight` (less than 10) land on the same atom, multiply through
1198
+ * to a pixel value. Atoms with only one of the two are left alone.
1199
+ * @param bucket Per-scheme style map for the atom.
1200
+ * @param schemes Scheme names active for this parse.
1201
+ */
1202
+ function normalizeLineHeightToPx(bucket: Record<string, RNStyle>, schemes: readonly string[]): void {
1203
+ for (const scheme of schemes) {
1204
+ const style = bucket[scheme]
1205
+ if (!style) continue
1206
+ const { fontSize } = style
1207
+ const { lineHeight } = style
1208
+ if (typeof fontSize !== 'number' || typeof lineHeight !== 'number') continue
1209
+ if (lineHeight >= 10) continue
1210
+ style.lineHeight = Math.round(fontSize * lineHeight * 10_000) / 10_000
1211
+ }
1212
+ }
1213
+
1214
+ /**
1215
+ * Fold one declaration's resolved entries into every scheme's bucket on
1216
+ * the target atom.
1217
+ * @param decl Lightningcss declaration to convert.
1218
+ * @param bucket Per-scheme style map for the atom.
1219
+ * @param schemes Scheme names active for this parse.
1220
+ * @param ruleSchemeTables Per-scheme var tables (with rule-local overrides folded in).
1221
+ */
1222
+ function applyDeclarationToBucket(
1223
+ decl: LcDeclaration,
1224
+ bucket: Record<string, RNStyle>,
1225
+ schemes: readonly string[],
1226
+ ruleSchemeTables: ReadonlyMap<string, ReadonlyMap<string, string>>,
1227
+ ): void {
1228
+ for (const scheme of schemes) {
1229
+ const schemeBucket = bucket[scheme] ?? {}
1230
+ for (const [key, value] of declarationToRnEntries(decl, ruleSchemeTables.get(scheme))) {
1231
+ schemeBucket[key] = value
1232
+ }
1233
+ bucket[scheme] = schemeBucket
1234
+ }
1235
+ }
1236
+
1237
+ /**
1238
+ * Apply the composed-transform post-pass for a single atom: if any of
1239
+ * Tailwind's `--tw-translate-*` / `--tw-scale-*` / `--tw-skew-*` vars
1240
+ * were written, synthesize a single `transform` array and drop the
1241
+ * intermediate `translate`/`scale`/`rotate` shorthand entries.
1242
+ * @param bucket Per-scheme style map for the atom.
1243
+ * @param schemes Scheme names active for this parse.
1244
+ * @param ruleLocalVars Rule-local `--tw-*` vars.
1245
+ */
1246
+ function applyComposedTransform(
1247
+ bucket: Record<string, RNStyle>,
1248
+ schemes: readonly string[],
1249
+ ruleLocalVars: ReadonlyMap<string, string>,
1250
+ ): void {
1251
+ const composed = composeTransformFromVars(ruleLocalVars)
1252
+ if (composed.length === 0) return
1253
+ for (const scheme of schemes) {
1254
+ const schemeBucket = bucket[scheme] ?? {}
1255
+ delete schemeBucket.translate
1256
+ delete schemeBucket.scale
1257
+ delete schemeBucket.rotate
1258
+ schemeBucket.transform = composed
1259
+ bucket[scheme] = schemeBucket
1260
+ }
1261
+ }
1262
+
1263
+ /**
1264
+ * Synthesize an RN `transform` array from Tailwind v4's composable
1265
+ * `--tw-translate-x/y`, `--tw-scale-x/y`, `--tw-skew-x/y`, and
1266
+ * `--tw-rotate-x/y/z` custom properties. Returns an empty array when
1267
+ * none of those props were written, letting the caller skip the
1268
+ * post-pass.
1269
+ * @param ruleVars Rule-local `--tw-*` vars collected from the style rule.
1270
+ * @returns RN transform operations (possibly empty).
1271
+ */
1272
+ function composeTransformFromVars(ruleVars: ReadonlyMap<string, string>): readonly Record<string, string | number>[] {
1273
+ const ops: Record<string, string | number>[] = []
1274
+ addAxisOp(ops, 'translateX', ruleVars.get('--tw-translate-x'), resolveLengthExpression)
1275
+ addAxisOp(ops, 'translateY', ruleVars.get('--tw-translate-y'), resolveLengthExpression)
1276
+ addAxisOp(ops, 'scaleX', ruleVars.get('--tw-scale-x'), resolveNumberOrPercent)
1277
+ addAxisOp(ops, 'scaleY', ruleVars.get('--tw-scale-y'), resolveNumberOrPercent)
1278
+ addAxisOp(ops, 'skewX', ruleVars.get('--tw-skew-x'), extractAngleFromSkewFunction)
1279
+ addAxisOp(ops, 'skewY', ruleVars.get('--tw-skew-y'), extractAngleFromSkewFunction)
1280
+ return ops
1281
+ }
1282
+
1283
+ /**
1284
+ * Push `{<key>: resolved(raw)}` into `ops` when `raw` is present and the
1285
+ * resolver returns non-null. Keeps {@link composeTransformFromVars}
1286
+ * below the cognitive complexity threshold.
1287
+ * @param ops Target array to mutate.
1288
+ * @param key RN transform op key (e.g. `'translateX'`).
1289
+ * @param raw Rule-local var value (possibly undefined).
1290
+ * @param resolve Value-resolver for this axis type.
1291
+ */
1292
+ function addAxisOp<T extends string | number>(
1293
+ ops: Record<string, string | number>[],
1294
+ key: string,
1295
+ raw: string | undefined,
1296
+ resolve: (text: string) => T | null,
1297
+ ): void {
1298
+ if (!raw) return
1299
+ const value = resolve(raw)
1300
+ if (value !== null) ops.push({ [key]: value })
1301
+ }
1302
+
1303
+ /**
1304
+ * Resolve a CSS length expression into the value a RN transform op
1305
+ * accepts — pixels as a number, or a percentage string preserved
1306
+ * verbatim. Supports the shapes Tailwind v4 emits into `--tw-translate-*`:
1307
+ *
1308
+ * - Direct lengths: `16px`, `1rem`, bare `42`.
1309
+ * - Percentages: `100%`, `-100%`.
1310
+ * - Flat calc: `calc(0.25rem * 52)`.
1311
+ * - Fractional calc: `calc(1 / 2 * 100%)` (→ `translate-x-1/2`).
1312
+ * - Nested calc with sign flip: `calc(calc(1 / 3 * 100%) * -1)` (→ `-translate-x-1/3`).
1313
+ *
1314
+ * Returns null when the expression mixes units (`calc(100% - 10px)` —
1315
+ * RN can't express those) or contains a token the evaluator can't
1316
+ * interpret; the transform op is simply skipped in that case.
1317
+ * @param text Length expression text.
1318
+ * @returns Pixel number, percentage string, or null when unrepresentable.
1319
+ */
1320
+ function resolveLengthExpression(text: string): number | string | null {
1321
+ const trimmed = text.trim()
1322
+ if (trimmed.length === 0) return null
1323
+ const evaluated = evaluateLengthExpr(trimmed)
1324
+ if (!evaluated) return null
1325
+ if (evaluated.unit === '%') return `${stripTrailingZeros(evaluated.value)}%`
1326
+ if (evaluated.unit === 'rem') return evaluated.value * 16
1327
+ return evaluated.value
1328
+ }
1329
+
1330
+ /** Evaluated length + its unit. `''` means px or bare number. */
1331
+ interface EvaluatedLength {
1332
+ value: number
1333
+ unit: '%' | 'rem' | ''
1334
+ }
1335
+
1336
+ /**
1337
+ * Evaluate a CSS length expression to a `{value, unit}` pair.
1338
+ *
1339
+ * Strategy: detect the (at most one) unit suffix present in the text,
1340
+ * strip every `calc(` to `(`, strip the unit suffix from numeric tokens,
1341
+ * and run a small arithmetic evaluator. Mixed-unit expressions are
1342
+ * rejected because RN has no way to express `calc(100% - 10px)` in a
1343
+ * flat transform op.
1344
+ * @param text Raw CSS length expression (already trimmed).
1345
+ * @returns Evaluated length with its unit, or `null` when invalid.
1346
+ */
1347
+ function evaluateLengthExpr(text: string): EvaluatedLength | null {
1348
+ const units = detectUnits(text)
1349
+ if (units.length > 1) return null
1350
+ const unit = (units[0] ?? '') as EvaluatedLength['unit']
1351
+ const arithmetic = stripCalcAndUnits(text)
1352
+ const value = evaluateArithmetic(arithmetic)
1353
+ if (value === null || !Number.isFinite(value)) return null
1354
+ return { value, unit }
1355
+ }
1356
+
1357
+ /**
1358
+ * Detect which length units appear in the expression. Multi-unit
1359
+ * expressions (e.g. `calc(100% - 1rem)`) aren't representable in one RN
1360
+ * transform op, so we reject them.
1361
+ * @param text Length expression.
1362
+ * @returns Sorted, deduped unit list found in the text.
1363
+ */
1364
+ function detectUnits(text: string): readonly string[] {
1365
+ const found = new Set<string>()
1366
+ if (/\d%/.test(text)) found.add('%')
1367
+ if (/[\d.]rem\b/.test(text)) found.add('rem')
1368
+ if (/[\d.]px\b/.test(text)) found.add('px')
1369
+ return [...found]
1370
+ }
1371
+
1372
+ /**
1373
+ * Strip every `calc(` wrapper to a plain `(`, and strip `%` / `rem` /
1374
+ * `px` unit suffixes from numeric tokens. Result is a plain arithmetic
1375
+ * expression the evaluator can consume.
1376
+ * @param text Length expression.
1377
+ * @returns Arithmetic text suitable for {@link evaluateArithmetic}.
1378
+ */
1379
+ function stripCalcAndUnits(text: string): string {
1380
+ // Input is Tailwind's compiled CSS, not user-controlled — no ReDoS risk.
1381
+ // eslint-disable-next-line sonarjs/slow-regex
1382
+ return text.replaceAll(/\bcalc\s*\(/g, '(').replaceAll(/([\d.]+)(?:rem|px|%)/g, '$1')
1383
+ }
1384
+
1385
+ /**
1386
+ * Format a percentage number so `50` stays `"50%"` (not `"50.00000001%"`)
1387
+ * when float drift is in the low bits. Strips trailing-zero decimals.
1388
+ * @param value Percentage magnitude.
1389
+ * @returns Integer-ish string.
1390
+ */
1391
+ function stripTrailingZeros(value: number): string {
1392
+ const rounded = Math.round(value * 1_000_000) / 1_000_000
1393
+ return String(rounded)
1394
+ }
1395
+
1396
+ /**
1397
+ * Tiny recursive-descent evaluator for CSS arithmetic. Accepts `+`, `-`,
1398
+ * `*`, `/`, parens, and decimal numbers. Returns `null` on malformed
1399
+ * input — rejects anything the tokenizer can't classify.
1400
+ * @param text Arithmetic text (post {@link stripCalcAndUnits}).
1401
+ * @returns Evaluated number, or `null`.
1402
+ */
1403
+ function evaluateArithmetic(text: string): number | null {
1404
+ const tokens = tokenizeArithmetic(text)
1405
+ if (!tokens) return null
1406
+ const cursor = { index: 0 }
1407
+ const result = parseArithmeticExpr(tokens, cursor)
1408
+ if (cursor.index !== tokens.length) return null
1409
+ return result
1410
+ }
1411
+
1412
+ /**
1413
+ * Split arithmetic text into numeric and operator tokens. Returns null
1414
+ * when the text contains any character outside the allowed set.
1415
+ * @param text Arithmetic text.
1416
+ * @returns Token list, or null on unexpected character.
1417
+ */
1418
+ function tokenizeArithmetic(text: string): readonly string[] | null {
1419
+ const tokens: string[] = []
1420
+ let index = 0
1421
+ while (index < text.length) {
1422
+ const ch = text[index]!
1423
+ if (isArithmeticWhitespace(ch)) {
1424
+ index += 1
1425
+ } else if (isArithmeticOperator(ch)) {
1426
+ tokens.push(ch)
1427
+ index += 1
1428
+ } else if (isDigitOrDot(ch)) {
1429
+ const next = consumeNumber(text, index)
1430
+ tokens.push(text.slice(index, next))
1431
+ index = next
1432
+ } else {
1433
+ return null
1434
+ }
1435
+ }
1436
+ return tokens
1437
+ }
1438
+
1439
+ /**
1440
+ * Check whether `ch` is a whitespace character the arithmetic tokenizer
1441
+ * may skip.
1442
+ * @param ch Single-character string.
1443
+ * @returns True for space / tab / newline.
1444
+ */
1445
+ function isArithmeticWhitespace(ch: string): boolean {
1446
+ return ch === ' ' || ch === '\t' || ch === '\n'
1447
+ }
1448
+
1449
+ /**
1450
+ * Check whether `ch` is one of the arithmetic operator tokens.
1451
+ * @param ch Single-character string.
1452
+ * @returns True for `(`, `)`, `+`, `-`, `*`, `/`.
1453
+ */
1454
+ function isArithmeticOperator(ch: string): boolean {
1455
+ return ch === '(' || ch === ')' || ch === '+' || ch === '-' || ch === '*' || ch === '/'
1456
+ }
1457
+
1458
+ /**
1459
+ * Check whether `ch` belongs to a numeric token.
1460
+ * @param ch Single-character string.
1461
+ * @returns True for a digit `0`–`9` or `.`.
1462
+ */
1463
+ function isDigitOrDot(ch: string): boolean {
1464
+ return (ch >= '0' && ch <= '9') || ch === '.'
1465
+ }
1466
+
1467
+ /**
1468
+ * Advance past a numeric token starting at `start`.
1469
+ * @param text Source text.
1470
+ * @param start Index of the first digit or dot.
1471
+ * @returns Index just past the last digit-or-dot.
1472
+ */
1473
+ function consumeNumber(text: string, start: number): number {
1474
+ let index = start
1475
+ while (index < text.length && isDigitOrDot(text[index]!)) index += 1
1476
+ return index
1477
+ }
1478
+
1479
+ /**
1480
+ * Parse an additive expression: `term (('+'|'-') term)*`.
1481
+ * @param tokens Token list.
1482
+ * @param cursor Mutable cursor.
1483
+ * @param cursor.index Current token index; advanced past consumed tokens.
1484
+ * @returns Evaluated number, or `null` on parse failure.
1485
+ */
1486
+ function parseArithmeticExpr(tokens: readonly string[], cursor: { index: number }): number | null {
1487
+ let left = parseArithmeticTerm(tokens, cursor)
1488
+ if (left === null) return null
1489
+ while (cursor.index < tokens.length) {
1490
+ const op = tokens[cursor.index]
1491
+ if (op !== '+' && op !== '-') break
1492
+ cursor.index += 1
1493
+ const right = parseArithmeticTerm(tokens, cursor)
1494
+ if (right === null) return null
1495
+ left = op === '+' ? left + right : left - right
1496
+ }
1497
+ return left
1498
+ }
1499
+
1500
+ /**
1501
+ * Parse a multiplicative expression: `factor (('*'|'/') factor)*`.
1502
+ * @param tokens Token list.
1503
+ * @param cursor Mutable cursor.
1504
+ * @param cursor.index Current token index; advanced past consumed tokens.
1505
+ * @returns Evaluated number, or `null`.
1506
+ */
1507
+ function parseArithmeticTerm(tokens: readonly string[], cursor: { index: number }): number | null {
1508
+ let left = parseArithmeticFactor(tokens, cursor)
1509
+ if (left === null) return null
1510
+ while (cursor.index < tokens.length) {
1511
+ const op = tokens[cursor.index]
1512
+ if (op !== '*' && op !== '/') break
1513
+ cursor.index += 1
1514
+ const right = parseArithmeticFactor(tokens, cursor)
1515
+ if (right === null) return null
1516
+ left = op === '*' ? left * right : left / right
1517
+ }
1518
+ return left
1519
+ }
1520
+
1521
+ /**
1522
+ * Parse a factor: unary minus, parenthesised expression, or number.
1523
+ * @param tokens Token list.
1524
+ * @param cursor Mutable cursor.
1525
+ * @param cursor.index Current token index; advanced past consumed tokens.
1526
+ * @returns Evaluated number, or `null`.
1527
+ */
1528
+ function parseArithmeticFactor(tokens: readonly string[], cursor: { index: number }): number | null {
1529
+ if (cursor.index >= tokens.length) return null
1530
+ const tok = tokens[cursor.index]!
1531
+ if (tok === '-') {
1532
+ cursor.index += 1
1533
+ const right = parseArithmeticFactor(tokens, cursor)
1534
+ return right === null ? null : -right
1535
+ }
1536
+ if (tok === '+') {
1537
+ cursor.index += 1
1538
+ return parseArithmeticFactor(tokens, cursor)
1539
+ }
1540
+ if (tok === '(') {
1541
+ cursor.index += 1
1542
+ const inner = parseArithmeticExpr(tokens, cursor)
1543
+ if (cursor.index >= tokens.length || tokens[cursor.index] !== ')') return null
1544
+ cursor.index += 1
1545
+ return inner
1546
+ }
1547
+ const number_ = Number(tok)
1548
+ if (!Number.isFinite(number_)) return null
1549
+ cursor.index += 1
1550
+ return number_
1551
+ }
1552
+
1553
+ /**
1554
+ * Resolve a scale factor expressed as a percentage (`150%`) or number (`1.5`).
1555
+ * @param text Raw value.
1556
+ * @returns Scale number (e.g. 1.5 for 150%), or null.
1557
+ */
1558
+ function resolveNumberOrPercent(text: string): number | null {
1559
+ const trimmed = text.trim()
1560
+ const percent = /^(-?\d+(?:\.\d+)?)%$/.exec(trimmed)
1561
+ if (percent) return Number(percent[1]) / 100
1562
+ const bare = /^-?\d+(?:\.\d+)?$/.exec(trimmed)
1563
+ if (bare) return Number(trimmed)
1564
+ return null
1565
+ }
1566
+
1567
+ /**
1568
+ * Extract the angle from Tailwind's `skewX(12deg)` / `skewY(-5deg)` /
1569
+ * `skewX(calc(6deg * -1))` custom-property value shape. Returns null
1570
+ * when the inner expression doesn't reduce to a degree value.
1571
+ *
1572
+ * Tailwind v4 emits negative skew utilities as a nested `calc()`
1573
+ * (`-skew-x-6` → `skewX(calc(6deg * -1))`), so the inner body has to be
1574
+ * evaluated as arithmetic — a bare-angle regex silently drops those.
1575
+ * @param text Raw value.
1576
+ * @returns `<N>deg` string, or null.
1577
+ */
1578
+ function extractAngleFromSkewFunction(text: string): string | null {
1579
+ const trimmed = text.trim()
1580
+ if (!trimmed.endsWith(')')) return null
1581
+ const openIdx = trimmed.indexOf('(')
1582
+ if (openIdx < 5) return null
1583
+ const head = trimmed.slice(0, openIdx)
1584
+ if (head !== 'skewX' && head !== 'skewY') return null
1585
+ const inner = trimmed.slice(openIdx + 1, -1).trim()
1586
+ return resolveAngleExpression(inner)
1587
+ }
1588
+
1589
+ /**
1590
+ * Evaluate an expression whose single unit is `deg`. Strips `calc(`
1591
+ * wrappers and `deg` suffixes, runs the arithmetic evaluator, reapplies
1592
+ * `deg`. Returns null for unit mismatches or unparseable text.
1593
+ * @param text Angle expression (e.g. `6deg`, `calc(6deg * -1)`).
1594
+ * @returns `<N>deg`, or null when not representable.
1595
+ */
1596
+ function resolveAngleExpression(text: string): string | null {
1597
+ if (!/[\d.]deg\b/.test(text)) return null
1598
+ // Input is Tailwind's compiled CSS, not user-controlled — no ReDoS risk.
1599
+ // eslint-disable-next-line sonarjs/slow-regex
1600
+ const arithmetic = text.replaceAll(/\bcalc\s*\(/g, '(').replaceAll(/([\d.]+)deg/g, '$1')
1601
+ const value = evaluateArithmetic(arithmetic)
1602
+ if (value === null || !Number.isFinite(value)) return null
1603
+ return `${stripTrailingZeros(value)}deg`
1604
+ }
1605
+
1606
+ /**
1607
+ * Scan Tailwind's compiled CSS for `:root, :host { --x: y; … }` blocks
1608
+ * and pull the custom-property declarations out. This captures every
1609
+ * theme token Tailwind resolved — including tokens imported from the
1610
+ * user's secondary `@import` files (e.g. `rnwind/css`'s
1611
+ * `--duration-normal: 220ms`) — without rnwind having to re-implement
1612
+ * `@import` resolution.
1613
+ *
1614
+ * Regex-free scanner: finds `:root` prefixes, walks forward with
1615
+ * brace-depth tracking to find the matching block close, then extracts
1616
+ * every `--name: value;` pair with a paren-balanced walker so commas
1617
+ * inside `rgb(0, 0, 0)` don't confuse the split.
1618
+ * @param css Tailwind's compiled CSS.
1619
+ * @returns Map of custom-property name → resolved value.
1620
+ */
1621
+ function extractRootCustomProperties(css: string): Map<string, string> {
1622
+ const out = new Map<string, string>()
1623
+ let cursor = 0
1624
+ while (cursor < css.length) {
1625
+ const blockEnd = consumeNextRootBlock(css, cursor, out)
1626
+ if (blockEnd === -1) break
1627
+ cursor = blockEnd + 1
1628
+ }
1629
+ return out
1630
+ }
1631
+
1632
+ /**
1633
+ * Locate the next `:root` block from `cursor`, extract its custom
1634
+ * properties into `out`, and return the index of its closing brace.
1635
+ * Split out from {@link extractRootCustomProperties} to keep complexity
1636
+ * below the cap.
1637
+ * @param css Source CSS.
1638
+ * @param cursor Start index for the search.
1639
+ * @param out Destination map, mutated.
1640
+ * @returns Index of the closing brace, or -1 when no block remains.
1641
+ */
1642
+ function consumeNextRootBlock(css: string, cursor: number, out: Map<string, string>): number {
1643
+ const head = css.indexOf(':root', cursor)
1644
+ if (head === -1) return -1
1645
+ const brace = css.indexOf('{', head)
1646
+ if (brace === -1) return -1
1647
+ const blockEnd = findMatchingClose(css, brace + 1)
1648
+ if (blockEnd === -1) return -1
1649
+ collectCustomDeclarations(css.slice(brace + 1, blockEnd), out)
1650
+ return blockEnd
1651
+ }
1652
+
1653
+ /**
1654
+ * Parse the body of a `:root` block — a `;`-separated list of `--name:
1655
+ * value` declarations — into the output map. Top-level `;` split is
1656
+ * paren-aware so `rgb(0, 0, 0)` doesn't fragment the list.
1657
+ * @param body Block body text (between braces).
1658
+ * @param out Destination map, mutated.
1659
+ */
1660
+ function collectCustomDeclarations(body: string, out: Map<string, string>): void {
1661
+ for (const declaration of topLevelSplit(body, ';')) {
1662
+ const colon = declaration.indexOf(':')
1663
+ if (colon === -1) continue
1664
+ const name = declaration.slice(0, colon).trim()
1665
+ const value = declaration.slice(colon + 1).trim()
1666
+ if (name.startsWith('--') && value.length > 0) out.set(name, value)
1667
+ }
1668
+ }
1669
+
1670
+ /**
1671
+ * Walk forward from `start` tracking brace depth; return the index of
1672
+ * the matching `}` for the opener just before `start`.
1673
+ * @param source Source text.
1674
+ * @param start Index just past the opening `{`.
1675
+ * @returns Index of matching `}`, or `-1` on imbalance.
1676
+ */
1677
+ function findMatchingClose(source: string, start: number): number {
1678
+ let depth = 1
1679
+ for (let index = start; index < source.length; index += 1) {
1680
+ const ch = source[index]
1681
+ if (ch === '{') depth += 1
1682
+ else if (ch === '}') {
1683
+ depth -= 1
1684
+ if (depth === 0) return index
1685
+ }
1686
+ }
1687
+ return -1
1688
+ }
1689
+
1690
+ /**
1691
+ * Merge rule-local custom vars into every scheme's var table. Creates
1692
+ * fresh maps so the rule pass doesn't mutate the shared parser state.
1693
+ * @param schemeTables Base per-scheme var tables.
1694
+ * @param ruleVars Rule-local `--tw-*` overrides.
1695
+ * @returns Merged per-scheme tables.
1696
+ */
1697
+ function mergeRuleVars(
1698
+ schemeTables: ReadonlyMap<string, ReadonlyMap<string, string>>,
1699
+ ruleVars: ReadonlyMap<string, string>,
1700
+ ): Map<string, ReadonlyMap<string, string>> {
1701
+ if (ruleVars.size === 0) return new Map(schemeTables)
1702
+ const out = new Map<string, ReadonlyMap<string, string>>()
1703
+ for (const [scheme, table] of schemeTables) {
1704
+ const merged = new Map(table)
1705
+ for (const [k, v] of ruleVars) merged.set(k, v)
1706
+ out.set(scheme, merged)
1707
+ }
1708
+ return out
1709
+ }