rnwind 0.0.4 → 0.0.5

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 (127) hide show
  1. package/lib/cjs/core/normalize-classname.cjs +25 -0
  2. package/lib/cjs/core/normalize-classname.cjs.map +1 -0
  3. package/lib/cjs/core/normalize-classname.d.ts +10 -0
  4. package/lib/cjs/core/style-builder/build-style.cjs +258 -58
  5. package/lib/cjs/core/style-builder/build-style.cjs.map +1 -1
  6. package/lib/cjs/core/style-builder/build-style.d.ts +6 -1
  7. package/lib/cjs/core/style-builder/union-builder.cjs +37 -3
  8. package/lib/cjs/core/style-builder/union-builder.cjs.map +1 -1
  9. package/lib/cjs/core/style-builder/union-builder.d.ts +21 -1
  10. package/lib/cjs/metro/dts.cjs +7 -16
  11. package/lib/cjs/metro/dts.cjs.map +1 -1
  12. package/lib/cjs/metro/dts.d.ts +2 -4
  13. package/lib/cjs/metro/state.cjs +30 -78
  14. package/lib/cjs/metro/state.cjs.map +1 -1
  15. package/lib/cjs/metro/state.d.ts +8 -25
  16. package/lib/cjs/metro/transformer.cjs +193 -34
  17. package/lib/cjs/metro/transformer.cjs.map +1 -1
  18. package/lib/cjs/metro/with-config.cjs +2 -2
  19. package/lib/cjs/metro/with-config.cjs.map +1 -1
  20. package/lib/cjs/metro/with-config.d.ts +11 -26
  21. package/lib/cjs/metro/wrap-imports.cjs +273 -0
  22. package/lib/cjs/metro/wrap-imports.cjs.map +1 -0
  23. package/lib/cjs/metro/wrap-imports.d.ts +26 -0
  24. package/lib/cjs/runtime/components/rnwind-provider.cjs +0 -17
  25. package/lib/cjs/runtime/components/rnwind-provider.cjs.map +1 -1
  26. package/lib/cjs/runtime/components/rnwind-provider.d.ts +0 -14
  27. package/lib/cjs/runtime/hooks/use-css.cjs +16 -10
  28. package/lib/cjs/runtime/hooks/use-css.cjs.map +1 -1
  29. package/lib/cjs/runtime/hooks/use-css.d.ts +15 -9
  30. package/lib/cjs/runtime/index.cjs +11 -13
  31. package/lib/cjs/runtime/index.cjs.map +1 -1
  32. package/lib/cjs/runtime/index.d.ts +4 -9
  33. package/lib/cjs/runtime/lookup-css.cjs +10 -0
  34. package/lib/cjs/runtime/lookup-css.cjs.map +1 -1
  35. package/lib/cjs/runtime/lookup-css.d.ts +7 -0
  36. package/lib/cjs/runtime/resolve.cjs +348 -0
  37. package/lib/cjs/runtime/resolve.cjs.map +1 -0
  38. package/lib/cjs/runtime/resolve.d.ts +61 -0
  39. package/lib/cjs/runtime/wrap.cjs +254 -0
  40. package/lib/cjs/runtime/wrap.cjs.map +1 -0
  41. package/lib/cjs/runtime/wrap.d.ts +37 -0
  42. package/lib/cjs/testing/index.cjs +81 -50
  43. package/lib/cjs/testing/index.cjs.map +1 -1
  44. package/lib/esm/core/normalize-classname.d.ts +10 -0
  45. package/lib/esm/core/normalize-classname.mjs +23 -0
  46. package/lib/esm/core/normalize-classname.mjs.map +1 -0
  47. package/lib/esm/core/style-builder/build-style.d.ts +6 -1
  48. package/lib/esm/core/style-builder/build-style.mjs +258 -58
  49. package/lib/esm/core/style-builder/build-style.mjs.map +1 -1
  50. package/lib/esm/core/style-builder/union-builder.d.ts +21 -1
  51. package/lib/esm/core/style-builder/union-builder.mjs +37 -3
  52. package/lib/esm/core/style-builder/union-builder.mjs.map +1 -1
  53. package/lib/esm/metro/dts.d.ts +2 -4
  54. package/lib/esm/metro/dts.mjs +7 -16
  55. package/lib/esm/metro/dts.mjs.map +1 -1
  56. package/lib/esm/metro/state.d.ts +8 -25
  57. package/lib/esm/metro/state.mjs +30 -76
  58. package/lib/esm/metro/state.mjs.map +1 -1
  59. package/lib/esm/metro/transformer.mjs +194 -35
  60. package/lib/esm/metro/transformer.mjs.map +1 -1
  61. package/lib/esm/metro/with-config.d.ts +11 -26
  62. package/lib/esm/metro/with-config.mjs +2 -2
  63. package/lib/esm/metro/with-config.mjs.map +1 -1
  64. package/lib/esm/metro/wrap-imports.d.ts +26 -0
  65. package/lib/esm/metro/wrap-imports.mjs +250 -0
  66. package/lib/esm/metro/wrap-imports.mjs.map +1 -0
  67. package/lib/esm/runtime/components/rnwind-provider.d.ts +0 -14
  68. package/lib/esm/runtime/components/rnwind-provider.mjs +1 -17
  69. package/lib/esm/runtime/components/rnwind-provider.mjs.map +1 -1
  70. package/lib/esm/runtime/hooks/use-css.d.ts +15 -9
  71. package/lib/esm/runtime/hooks/use-css.mjs +16 -10
  72. package/lib/esm/runtime/hooks/use-css.mjs.map +1 -1
  73. package/lib/esm/runtime/index.d.ts +4 -9
  74. package/lib/esm/runtime/index.mjs +4 -4
  75. package/lib/esm/runtime/index.mjs.map +1 -1
  76. package/lib/esm/runtime/lookup-css.d.ts +7 -0
  77. package/lib/esm/runtime/lookup-css.mjs +10 -1
  78. package/lib/esm/runtime/lookup-css.mjs.map +1 -1
  79. package/lib/esm/runtime/resolve.d.ts +61 -0
  80. package/lib/esm/runtime/resolve.mjs +341 -0
  81. package/lib/esm/runtime/resolve.mjs.map +1 -0
  82. package/lib/esm/runtime/wrap.d.ts +37 -0
  83. package/lib/esm/runtime/wrap.mjs +251 -0
  84. package/lib/esm/runtime/wrap.mjs.map +1 -0
  85. package/lib/esm/testing/index.mjs +84 -53
  86. package/lib/esm/testing/index.mjs.map +1 -1
  87. package/package.json +2 -1
  88. package/src/core/normalize-classname.ts +19 -0
  89. package/src/core/style-builder/build-style.ts +286 -55
  90. package/src/core/style-builder/union-builder.ts +36 -3
  91. package/src/metro/dts.ts +7 -19
  92. package/src/metro/state.ts +29 -74
  93. package/src/metro/transformer.ts +190 -34
  94. package/src/metro/with-config.ts +13 -28
  95. package/src/metro/wrap-imports.ts +260 -0
  96. package/src/runtime/components/rnwind-provider.tsx +0 -17
  97. package/src/runtime/hooks/use-css.ts +17 -11
  98. package/src/runtime/index.ts +3 -26
  99. package/src/runtime/lookup-css.ts +10 -0
  100. package/src/runtime/resolve.ts +381 -0
  101. package/src/runtime/wrap.tsx +267 -0
  102. package/src/testing/index.ts +106 -56
  103. package/lib/cjs/core/parser/text-truncate.cjs +0 -78
  104. package/lib/cjs/core/parser/text-truncate.cjs.map +0 -1
  105. package/lib/cjs/metro/transform-ast.cjs +0 -1472
  106. package/lib/cjs/metro/transform-ast.cjs.map +0 -1
  107. package/lib/cjs/metro/transform-ast.d.ts +0 -88
  108. package/lib/cjs/runtime/haptics.cjs +0 -113
  109. package/lib/cjs/runtime/haptics.cjs.map +0 -1
  110. package/lib/cjs/runtime/haptics.d.ts +0 -48
  111. package/lib/cjs/runtime/interactive-box.cjs +0 -35
  112. package/lib/cjs/runtime/interactive-box.cjs.map +0 -1
  113. package/lib/cjs/runtime/interactive-box.d.ts +0 -40
  114. package/lib/esm/core/parser/text-truncate.mjs +0 -75
  115. package/lib/esm/core/parser/text-truncate.mjs.map +0 -1
  116. package/lib/esm/metro/transform-ast.d.ts +0 -88
  117. package/lib/esm/metro/transform-ast.mjs +0 -1451
  118. package/lib/esm/metro/transform-ast.mjs.map +0 -1
  119. package/lib/esm/runtime/haptics.d.ts +0 -48
  120. package/lib/esm/runtime/haptics.mjs +0 -110
  121. package/lib/esm/runtime/haptics.mjs.map +0 -1
  122. package/lib/esm/runtime/interactive-box.d.ts +0 -40
  123. package/lib/esm/runtime/interactive-box.mjs +0 -33
  124. package/lib/esm/runtime/interactive-box.mjs.map +0 -1
  125. package/src/metro/transform-ast.ts +0 -1729
  126. package/src/runtime/haptics.ts +0 -120
  127. package/src/runtime/interactive-box.tsx +0 -57
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rnwind",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "description": "Tailwind for React Native",
5
5
  "author": "https://github.com/sagltd",
6
6
  "license": "MIT",
@@ -56,6 +56,7 @@
56
56
  ],
57
57
  "scripts": {
58
58
  "build": "rollup -c rollup.config.mjs",
59
+ "bench": "bun run bench/resolve.bench.ts",
59
60
  "test": "bun test ./__tests__",
60
61
  "test:coverage": "bun test ./__tests__ --coverage",
61
62
  "typecheck": "tsc --noEmit",
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Normalize a className for molecule keying: trim, collapse runs of
3
+ * whitespace, and drop exact-duplicate tokens — but PRESERVE ORDER.
4
+ * Tailwind is last-wins for conflicting utilities (`p-4 p-2` ≠ `p-2 p-4`),
5
+ * so sorting would corrupt the merge. Build-time (molecule keys) and
6
+ * runtime (lookup) call the identical function so their keys always match.
7
+ * @param className Raw className string.
8
+ * @returns Normalized, order-preserving className.
9
+ */
10
+ export function normalizeClassName(className: string): string {
11
+ const seen = new Set<string>()
12
+ const out: string[] = []
13
+ for (const token of className.trim().split(/\s+/)) {
14
+ if (token.length === 0 || seen.has(token)) continue
15
+ seen.add(token)
16
+ out.push(token)
17
+ }
18
+ return out.join(' ')
19
+ }
@@ -1,4 +1,5 @@
1
1
  import type { KeyframeBlock, RNStyle, SchemedStyle } from '../parser'
2
+ import { normalizeClassName } from '../normalize-classname'
2
3
 
3
4
  /** Match atom names like `border-hairline`, `h-hairline`, `border-t-hairline`, etc. */
4
5
  const HAIRLINE_ATOM = /-hairline$/
@@ -179,61 +180,96 @@ function prepareAtomValue(atomName: string, style: RNStyle, keyframes: ReadonlyM
179
180
  }
180
181
 
181
182
  /**
182
- * Per-file value deduplicator interns each unique serialized atom
183
- * value once and emits `const _s<N> = <value>` at module scope. Scheme
184
- * entries reference the const instead of inlining the literal.
185
- *
186
- * Wins: (1) smaller bundle bytes for themes with many atoms sharing a
187
- * style shape; (2) stable `===` inside one scheme so two atoms
188
- * resolving to the same value yield the same object reference.
183
+ * Decide which serialized atom values get hoisted to a shared `const`.
184
+ * A value is hoisted ONLY when ≥2 atoms share it then one
185
+ * `const _s<N> = <value>` saves the repeated bytes AND gives those atoms
186
+ * one shared object (reference identity). A value used once is inlined
187
+ * directly at its atom (`"-m-2": {"margin":-8}`) hoisting a singleton
188
+ * would only add bytes. First-seen order keeps the const indices stable
189
+ * across workers.
190
+ * @param entries `[atomName, serializedValue]` pairs (atom-sorted).
191
+ * @returns `{ constFor }` value→const-name map + `decls` source lines.
189
192
  */
190
- class ValueDeduper {
191
- private readonly byText = new Map<string, string>()
192
- private readonly decls: string[] = []
193
-
194
- intern(serialized: string): string {
195
- const existing = this.byText.get(serialized)
196
- if (existing) return existing
197
- const name = `_s${this.decls.length}`
198
- this.decls.push(`const ${name} = ${serialized}`)
199
- this.byText.set(serialized, name)
200
- return name
193
+ function planValueConsts(entries: readonly (readonly [string, string])[]): {
194
+ constFor: ReadonlyMap<string, string>
195
+ decls: readonly string[]
196
+ } {
197
+ const counts = new Map<string, number>()
198
+ for (const [, value] of entries) counts.set(value, (counts.get(value) ?? 0) + 1)
199
+ const constFor = new Map<string, string>()
200
+ const decls: string[] = []
201
+ for (const [value, count] of counts) {
202
+ if (count < 2) continue
203
+ const name = `_s${decls.length}`
204
+ constFor.set(value, name)
205
+ decls.push(`const ${name} = ${value}`)
201
206
  }
207
+ return { constFor, decls }
208
+ }
202
209
 
203
- get declarations(): readonly string[] {
204
- return this.decls
205
- }
210
+ /**
211
+ * Serialize a scheme's molecule map into a `registerMolecules(...)` object
212
+ * literal, sorted by className for byte-deterministic output.
213
+ * @param molecules normalized className → pre-merged style object.
214
+ * @returns Object-literal source (`null` when empty).
215
+ */
216
+ function serializeMolecules(molecules: Record<string, RNStyle> | undefined): string | null {
217
+ if (!molecules) return null
218
+ const keys = Object.keys(molecules).toSorted((a, b) => a.localeCompare(b))
219
+ if (keys.length === 0) return null
220
+ const body = keys.map((cn) => ` ${JSON.stringify(cn)}: ${JSON.stringify(molecules[cn])},`)
221
+ return ['{', ...body, '}'].join('\n')
206
222
  }
207
223
 
208
224
  /**
209
225
  * Render one scheme file's source. `entries` is the list of atoms this
210
226
  * scheme contributes — for `common` every atom's canonical value; for
211
227
  * a variant only atoms whose value differs from canonical. Hairline
212
- * atoms in this file trigger the `StyleSheet` import.
228
+ * atoms in this file trigger the `StyleSheet` import. Pre-merged
229
+ * molecules (when present) are registered alongside the atoms so the
230
+ * runtime resolver's molecule-first path is populated.
213
231
  * @param schemeName Registry key (`'common'` or the variant name).
214
232
  * @param entries `[atomName, serializedValue]` pairs to emit.
233
+ * @param molecules Pre-merged className → style map for this scheme.
215
234
  * @returns JS source text.
216
235
  */
217
- function renderSchemeFile(schemeName: string, entries: readonly (readonly [string, string])[]): string {
236
+ function renderSchemeFile(
237
+ schemeName: string,
238
+ entries: readonly (readonly [string, string])[],
239
+ molecules?: Record<string, RNStyle>,
240
+ ): string {
218
241
  const needsStyleSheet = entries.some(([atom]) => isHairlineAtom(atom))
219
- const deduper = new ValueDeduper()
220
- const recordLines: string[] = []
221
- for (const [atom, value] of entries) {
222
- const ref = deduper.intern(value)
223
- recordLines.push(` ${JSON.stringify(atom)}: ${ref},`)
224
- }
242
+ const { constFor, decls } = planValueConsts(entries)
243
+ const recordLines = entries.map(([atom, value]) => ` ${JSON.stringify(atom)}: ${constFor.get(value) ?? value},`)
244
+ const moleculeLiteral = serializeMolecules(molecules)
225
245
 
246
+ const imports = ['registerAtoms']
247
+ if (moleculeLiteral) imports.push('registerMolecules')
226
248
  const lines: string[] = []
227
249
  if (needsStyleSheet) lines.push(`import { StyleSheet } from 'react-native'`)
228
- lines.push(`import { registerAtoms } from 'rnwind'`, ``)
229
- if (deduper.declarations.length > 0) {
230
- for (const decl of deduper.declarations) lines.push(decl)
250
+ lines.push(`import { ${imports.join(', ')} } from 'rnwind'`, ``)
251
+ if (decls.length > 0) {
252
+ for (const decl of decls) lines.push(decl)
231
253
  lines.push(``)
232
254
  }
233
255
  lines.push(`registerAtoms(${JSON.stringify(schemeName)}, {`, ...recordLines, `})`, ``)
256
+ if (moleculeLiteral) lines.push(`registerMolecules(${JSON.stringify(schemeName)}, ${moleculeLiteral})`, ``)
234
257
  return lines.join('\n')
235
258
  }
236
259
 
260
+ /**
261
+ * Serialize a feature map (atom name → JSON-able value: gradient info or
262
+ * haptic request) into a stable JS object literal for the manifest.
263
+ * Sorted by key so the output is byte-deterministic across workers.
264
+ * @param map Atom name → feature value.
265
+ * @returns Object-literal source.
266
+ */
267
+ function serializeFeatureMap(map: ReadonlyMap<string, unknown>): string {
268
+ const entries = [...map.entries()].toSorted((a, b) => a[0].localeCompare(b[0]))
269
+ const body = entries.map(([key, value]) => `${JSON.stringify(key)}: ${JSON.stringify(value)}`).join(', ')
270
+ return `{ ${body} }`
271
+ }
272
+
237
273
  /**
238
274
  * Render the JS-object literal for the responsive-breakpoint table the
239
275
  * runtime registers at manifest-load time. Sorted by ascending px
@@ -250,35 +286,215 @@ function serializeBreakpoints(breakpoints: ReadonlyMap<string, number>): string
250
286
  }
251
287
 
252
288
  /**
253
- * Render the manifest module. Eager-imports `common.style.js` (every
254
- * rewritten source file pulls this via a transitive side-effect
255
- * import), registers the responsive-breakpoint table once, and lazy-
256
- * requires every variant scheme's file through an inline require —
257
- * first call in `ensureSchemeLoaded(name)` triggers the scheme
258
- * module's evaluation; Metro's module cache makes subsequent calls
259
- * no-ops.
289
+ * Render the manifest module. EAGER-imports `common.style.js` AND every
290
+ * variant scheme file so every scheme's atoms register the moment the
291
+ * manifest evaluates no lazy `require`. Lazy loading raced the cold
292
+ * start: `RnwindProvider` calls `loadScheme(scheme)` on its first render,
293
+ * but on a cold boot the manifest (hence `registerSchemeLoader`) may not
294
+ * have evaluated yet, so that call no-ops and the active variant's atoms
295
+ * never load — scheme-dependent styles fall back to `common` (the light
296
+ * default) until a reload. Eager imports remove the race entirely; the
297
+ * variant files are small diffs, so the upfront cost is negligible.
298
+ * `ensureSchemeLoaded` stays exported as a no-op for API compatibility.
260
299
  * @param variants Variant scheme names (no `base`, no `common`).
261
300
  * @param breakpoints Responsive breakpoint name → px-threshold map.
301
+ * @param gradients Atom → gradient info for `registerGradients`.
302
+ * @param haptics Atom → haptic request for `registerHaptics`.
262
303
  * @returns JS source text.
263
304
  */
264
- function renderManifest(variants: readonly string[], breakpoints: ReadonlyMap<string, number>): string {
265
- const lines: string[] = [
266
- `import { registerSchemeLoader, registerBreakpoints } from 'rnwind'`,
267
- `import './common.style'`,
305
+ function renderManifest(
306
+ variants: readonly string[],
307
+ breakpoints: ReadonlyMap<string, number>,
308
+ gradients: ReadonlyMap<string, unknown>,
309
+ haptics: ReadonlyMap<string, unknown>,
310
+ ): string {
311
+ const imports = ['registerSchemeLoader', 'registerBreakpoints']
312
+ if (gradients.size > 0) imports.push('registerGradients')
313
+ if (haptics.size > 0) imports.push('registerHaptics')
314
+ const lines: string[] = [`import { ${imports.join(', ')} } from 'rnwind'`, `import './common.style'`]
315
+ for (const variant of variants) lines.push(`import ${JSON.stringify(`./${variant}.style`)}`)
316
+ lines.push(``, `registerBreakpoints(${serializeBreakpoints(breakpoints)})`)
317
+ if (gradients.size > 0) lines.push(`registerGradients(${serializeFeatureMap(gradients)})`)
318
+ if (haptics.size > 0) lines.push(`registerHaptics(${serializeFeatureMap(haptics)})`)
319
+ lines.push(
320
+ ``,
321
+ `function ensureSchemeLoaded(_name) {}`,
268
322
  ``,
269
- `registerBreakpoints(${serializeBreakpoints(breakpoints)})`,
323
+ `registerSchemeLoader(ensureSchemeLoaded)`,
270
324
  ``,
271
- ]
272
- if (variants.length === 0) {
273
- lines.push(`function ensureSchemeLoaded(_name) {}`, ``, `registerSchemeLoader(ensureSchemeLoaded)`, ``, `export { ensureSchemeLoaded }`, ``)
274
- return lines.join('\n')
325
+ `export { ensureSchemeLoaded }`,
326
+ ``,
327
+ )
328
+ return lines.join('\n')
329
+ }
330
+
331
+ /**
332
+ * Whether a resolved style carries a nested safe-area marker — molecules
333
+ * can't pre-bake these because the inset value is per-render.
334
+ * @param style Raw resolved RN style (pre-envelope).
335
+ * @returns True when any value is a `{__safe: ...}` marker.
336
+ */
337
+ function hasSafeMarker(style: RNStyle): boolean {
338
+ for (const key of Object.keys(style)) {
339
+ const value = style[key]
340
+ if (typeof value !== 'object' || !value) continue
341
+ if ('__safe' in value) return true
275
342
  }
276
- lines.push(`const LOADERS = {`)
343
+ return false
344
+ }
345
+
346
+ /**
347
+ * Whether a resolved style has font-scale-sensitive props. Molecules
348
+ * can't pre-bake these because `fontSize`/`lineHeight` scale per-render
349
+ * with `useWindowDimensions().fontScale`.
350
+ * @param style Resolved RN style.
351
+ * @returns True when `fontSize` or `lineHeight` is present.
352
+ */
353
+ function hasFontScaleProperty(style: RNStyle): boolean {
354
+ return 'fontSize' in style || 'lineHeight' in style
355
+ }
356
+
357
+ /**
358
+ * Whether a token is a feature-only utility (gradient stop/direction,
359
+ * haptic, or text-truncate) that contributes NO RN `style` — the runtime
360
+ * resolver folds these in via `attachFeatures`, so they don't disqualify
361
+ * a molecule, they just merge nothing.
362
+ * @param token Atom name.
363
+ * @param gradients Gradient feature map.
364
+ * @param haptics Haptic feature map.
365
+ * @returns True when the token is a non-style feature.
366
+ */
367
+ function isFeatureToken(token: string, gradients: ReadonlyMap<string, unknown>, haptics: ReadonlyMap<string, unknown>): boolean {
368
+ if (gradients.has(token) || haptics.has(token)) return true
369
+ return token === 'truncate' || token === 'text-ellipsis' || token === 'text-clip' || token.startsWith('line-clamp-')
370
+ }
371
+
372
+ /**
373
+ * Resolve one atom's value under a scheme: the scheme's own non-empty
374
+ * bucket, falling back to canonical. `common` always reads canonical.
375
+ * @param schemed Parser-produced per-scheme bucket.
376
+ * @param scheme Scheme key (`'common'` or a variant name).
377
+ * @returns The atom's RN style for that scheme, or undefined.
378
+ */
379
+ function schemeValueOf(schemed: SchemedStyle, scheme: string): RNStyle | undefined {
380
+ if (scheme === COMMON_SCHEME) return canonicalValue(schemed)
381
+ const own = (schemed as Readonly<Record<string, RNStyle>>)[scheme]
382
+ return isNonEmptyStyle(own) ? own : canonicalValue(schemed)
383
+ }
384
+
385
+ /**
386
+ * Pre-merge a normalized className's atoms into ONE RN style object for a
387
+ * scheme, or null when the className is NOT molecule-eligible. A
388
+ * className is eligible only when every token is context-independent:
389
+ * - no variant prefix (`active:` / `focus:` / `md:` / `dark:` — anything
390
+ * with a `:`), so scheme/state/breakpoint gating never applies,
391
+ * - no `*-hairline`, `*-safe`, or font-scale (`fontSize`/`lineHeight`)
392
+ * atom, whose value is resolved per-render.
393
+ * Feature-only tokens (gradient / haptic / truncate) are skipped, not
394
+ * disqualifying — the runtime folds them in via `attachFeatures`. Unknown
395
+ * tokens disqualify so the atom path still surfaces the dev warning.
396
+ * @param tokens Normalized className tokens (order preserved).
397
+ * @param scheme Scheme key to resolve each atom under.
398
+ * @param resolved Per-atom schemed styles.
399
+ * @param keyframes Keyframes to inline into `animationName`.
400
+ * @param gradients Gradient feature map.
401
+ * @param haptics Haptic feature map.
402
+ * @returns Merged style object, or null when not eligible.
403
+ */
404
+ function mergeMolecule(
405
+ tokens: readonly string[],
406
+ scheme: string,
407
+ resolved: ReadonlyMap<string, SchemedStyle>,
408
+ keyframes: ReadonlyMap<string, KeyframeBlock>,
409
+ gradients: ReadonlyMap<string, unknown>,
410
+ haptics: ReadonlyMap<string, unknown>,
411
+ ): RNStyle | null {
412
+ const merged: RNStyle = {}
413
+ for (const token of tokens) {
414
+ if (token.includes(':')) return null
415
+ if (isFeatureToken(token, gradients, haptics)) continue
416
+ if (isHairlineAtom(token)) return null
417
+ const schemed = resolved.get(token)
418
+ if (!schemed) return null
419
+ const raw = schemeValueOf(schemed, scheme)
420
+ if (!raw) continue
421
+ if (hasSafeMarker(raw) || hasFontScaleProperty(raw)) return null
422
+ Object.assign(merged, inlineAnimationName(raw, keyframes))
423
+ }
424
+ return merged
425
+ }
426
+
427
+ /**
428
+ * Emit each variant's molecule for one className — but only when the
429
+ * variant's merge DIFFERS from common (runtime falls back to common).
430
+ * @param normalized Normalized className key.
431
+ * @param tokens Normalized className tokens.
432
+ * @param commonText Serialized common-scheme merge for the diff check.
433
+ * @param variants Variant scheme names.
434
+ * @param variantMaps Mutable per-variant molecule collectors.
435
+ * @param resolved Per-atom schemed styles.
436
+ * @param keyframes Keyframes to inline.
437
+ * @param gradients Gradient feature map.
438
+ * @param haptics Haptic feature map.
439
+ */
440
+ function addVariantMolecules(
441
+ normalized: string,
442
+ tokens: readonly string[],
443
+ commonText: string,
444
+ variants: readonly string[],
445
+ variantMaps: Record<string, Record<string, RNStyle>>,
446
+ resolved: ReadonlyMap<string, SchemedStyle>,
447
+ keyframes: ReadonlyMap<string, KeyframeBlock>,
448
+ gradients: ReadonlyMap<string, unknown>,
449
+ haptics: ReadonlyMap<string, unknown>,
450
+ ): void {
277
451
  for (const variant of variants) {
278
- lines.push(` ${JSON.stringify(variant)}: () => require(${JSON.stringify(`./${variant}.style`)}),`)
452
+ const variantMerged = mergeMolecule(tokens, variant, resolved, keyframes, gradients, haptics)
453
+ if (variantMerged === null) continue
454
+ if (JSON.stringify(variantMerged) !== commonText) variantMaps[variant][normalized] = variantMerged
279
455
  }
280
- lines.push(`}`, ``, `function ensureSchemeLoaded(name) {`, ` const loader = LOADERS[name]`, ` if (loader) loader()`, `}`, ``, `registerSchemeLoader(ensureSchemeLoaded)`, ``, `export { ensureSchemeLoaded }`, ``)
281
- return lines.join('\n')
456
+ }
457
+
458
+ /**
459
+ * Build per-scheme molecules for every literal className the project
460
+ * uses. Each eligible className gets a pre-merged style object under
461
+ * `common`; a variant only carries an entry when its merge DIFFERS from
462
+ * common (runtime falls back `molecules[scheme] ?? molecules.common`).
463
+ * @param literals Distinct literal className strings (raw).
464
+ * @param resolved Per-atom schemed styles.
465
+ * @param keyframes Keyframes to inline.
466
+ * @param variants Variant scheme names.
467
+ * @param gradients Gradient feature map.
468
+ * @param haptics Haptic feature map.
469
+ * @returns scheme → (normalized className → merged style).
470
+ */
471
+ function buildMolecules(
472
+ literals: readonly string[],
473
+ resolved: ReadonlyMap<string, SchemedStyle>,
474
+ keyframes: ReadonlyMap<string, KeyframeBlock>,
475
+ variants: readonly string[],
476
+ gradients: ReadonlyMap<string, unknown>,
477
+ haptics: ReadonlyMap<string, unknown>,
478
+ ): Record<string, Record<string, RNStyle>> {
479
+ const common: Record<string, RNStyle> = {}
480
+ const variantMaps: Record<string, Record<string, RNStyle>> = {}
481
+ for (const variant of variants) variantMaps[variant] = {}
482
+
483
+ for (const literal of literals) {
484
+ const normalized = normalizeClassName(literal)
485
+ if (normalized.length === 0) continue
486
+ const tokens = normalized.split(' ')
487
+ const commonMerged = mergeMolecule(tokens, COMMON_SCHEME, resolved, keyframes, gradients, haptics)
488
+ if (commonMerged === null) continue
489
+ common[normalized] = commonMerged
490
+ addVariantMolecules(normalized, tokens, JSON.stringify(commonMerged), variants, variantMaps, resolved, keyframes, gradients, haptics)
491
+ }
492
+
493
+ const out: Record<string, Record<string, RNStyle>> = { [COMMON_SCHEME]: common }
494
+ for (const variant of variants) {
495
+ if (Object.keys(variantMaps[variant]).length > 0) out[variant] = variantMaps[variant]
496
+ }
497
+ return out
282
498
  }
283
499
 
284
500
  /** Output of one build pass — one source per scheme plus the manifest. */
@@ -458,6 +674,11 @@ const EMPTY_BREAKPOINTS: ReadonlyMap<string, number> = new Map()
458
674
  * manifest emits `registerBreakpoints({...})` so the runtime can gate
459
675
  * `md:*` / `lg:*` atoms on `windowWidth`. Optional — empty when the
460
676
  * theme declares no breakpoints (legacy/test callers).
677
+ * @param gradients Gradient feature map (atom → role/colour) for the manifest + molecule eligibility.
678
+ * @param haptics Haptic feature map (atom → request) for the manifest + molecule eligibility.
679
+ * @param literals Distinct literal className strings — pre-merged into
680
+ * per-scheme molecules so the runtime resolver's O(1) molecule-first
681
+ * path is populated. Empty for legacy/test callers (atom path only).
461
682
  * @returns Per-scheme sources, manifest source, variant list.
462
683
  */
463
684
  export function buildSchemeSources(
@@ -466,6 +687,9 @@ export function buildSchemeSources(
466
687
  keyframes: ReadonlyMap<string, KeyframeBlock>,
467
688
  cache?: AtomSerializedCache,
468
689
  breakpoints: ReadonlyMap<string, number> = EMPTY_BREAKPOINTS,
690
+ gradients: ReadonlyMap<string, unknown> = EMPTY_FEATURE_MAP,
691
+ haptics: ReadonlyMap<string, unknown> = EMPTY_FEATURE_MAP,
692
+ literals: readonly string[] = EMPTY_LITERALS,
469
693
  ): BuildSchemeSourcesOutput {
470
694
  const variants = collectVariantSchemes(resolved)
471
695
  const commonEntries: (readonly [string, string])[] = []
@@ -481,20 +705,27 @@ export function buildSchemeSources(
481
705
  misses += collectAtomEntries(atom, schemed, canonical, variants, keyframes, commonEntries, variantEntries, cache)
482
706
  }
483
707
 
708
+ const molecules = buildMolecules(literals, resolved, keyframes, variants, gradients, haptics)
484
709
  const schemeSources: Record<string, string> = {
485
- [COMMON_SCHEME]: renderSchemeFile(COMMON_SCHEME, commonEntries),
710
+ [COMMON_SCHEME]: renderSchemeFile(COMMON_SCHEME, commonEntries, molecules[COMMON_SCHEME]),
486
711
  }
487
712
  for (const variant of variants) {
488
- schemeSources[variant] = renderSchemeFile(variant, variantEntries[variant])
713
+ schemeSources[variant] = renderSchemeFile(variant, variantEntries[variant], molecules[variant])
489
714
  }
490
715
 
491
716
  return {
492
717
  schemeSources,
493
- manifestSource: renderManifest(variants, breakpoints),
718
+ manifestSource: renderManifest(variants, breakpoints, gradients, haptics),
494
719
  variants,
495
720
  serializedMisses: misses,
496
721
  }
497
722
  }
498
723
 
724
+ /** Shared empty feature map default. */
725
+ const EMPTY_FEATURE_MAP: ReadonlyMap<string, unknown> = new Map()
726
+
727
+ /** Shared empty literal-list default (atom-only callers). */
728
+ const EMPTY_LITERALS: readonly string[] = []
729
+
499
730
  /** Registry key the runtime uses for the always-loaded fallback. */
500
731
  export const COMMON_SCHEME_NAME: string = COMMON_SCHEME
@@ -1,7 +1,7 @@
1
1
  import { createHash, randomBytes } from 'node:crypto'
2
2
  import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
3
3
  import path from 'node:path'
4
- import type { KeyframeBlock, SchemedStyle, TailwindParser } from '../parser'
4
+ import type { GradientAtomInfo, HapticRequest, KeyframeBlock, SchemedStyle, TailwindParser } from '../parser'
5
5
  import { buildSchemeSources, type AtomSerializedCache } from './build-style'
6
6
 
7
7
  /** Manifest module basename — the file SchemeProvider imports via the resolver. */
@@ -90,6 +90,17 @@ class UnionBuilder {
90
90
  private readonly parser: TailwindParser
91
91
  private readonly unionAtoms = new Map<string, SchemedStyle>()
92
92
  private readonly unionKeyframes = new Map<string, KeyframeBlock>()
93
+ /** atom name → gradient role/colour, surfaced into the manifest's `registerGradients`. */
94
+ private readonly unionGradients = new Map<string, GradientAtomInfo>()
95
+ /** atom name → haptic request, surfaced into the manifest's `registerHaptics`. */
96
+ private readonly unionHaptics = new Map<string, HapticRequest>()
97
+ /**
98
+ * Distinct literal className strings seen across all files, pre-merged
99
+ * into per-scheme molecules at write time. Accumulate-only (like
100
+ * `unionAtoms`): orphaned literals just yield unused molecules and get
101
+ * reaped on the next cold start, so no refcount is needed.
102
+ */
103
+ private readonly unionLiterals = new Set<string>()
93
104
  /**
94
105
  * Responsive breakpoints captured from the parser. Refreshed on every
95
106
  * `recordFile` / `ensureProjectScanned` so user-defined
@@ -169,6 +180,8 @@ class UnionBuilder {
169
180
  const parsed = await this.parser.parseProject()
170
181
  for (const [name, style] of parsed.atoms) this.unionAtoms.set(name, style)
171
182
  for (const [name, kf] of parsed.keyframes) this.unionKeyframes.set(name, kf)
183
+ for (const [name, gradient] of parsed.gradientAtoms) this.unionGradients.set(name, gradient)
184
+ for (const [name, haptic] of parsed.hapticAtoms) this.unionHaptics.set(name, haptic)
172
185
  this.breakpoints = parsed.breakpoints
173
186
  this.projectScanned = true
174
187
  })()
@@ -187,6 +200,7 @@ class UnionBuilder {
187
200
  * @param file Absolute source file path.
188
201
  * @param atoms Per-atom resolved schemed styles from this transform.
189
202
  * @param keyframes Keyframe blocks referenced by this file's atoms.
203
+ * @param literals
190
204
  * @returns `{ changed: true }` when the union shifted (new atom name,
191
205
  * removed atom name, or new keyframe) — the transformer uses this
192
206
  * to skip the serializer + `writeSchemes` when nothing changed.
@@ -195,8 +209,10 @@ class UnionBuilder {
195
209
  file: string,
196
210
  atoms: ReadonlyMap<string, SchemedStyle>,
197
211
  keyframes: ReadonlyMap<string, KeyframeBlock>,
212
+ literals: readonly string[] = [],
198
213
  ): Promise<{ changed: boolean }> {
199
214
  await this.ensureProjectScanned()
215
+ const literalAdded = this.recordLiterals(literals)
200
216
  const newAtomNames = new Set(atoms.keys())
201
217
  const previous = this.fileAtomSets.get(file)
202
218
  if (previous && setsEqual(previous, newAtomNames)) {
@@ -211,12 +227,29 @@ class UnionBuilder {
211
227
  if (!this.unionKeyframes.has(name)) keyframeAdded = true
212
228
  this.unionKeyframes.set(name, kf)
213
229
  }
214
- return { changed: keyframeAdded }
230
+ return { changed: keyframeAdded || literalAdded }
215
231
  }
216
232
  this.applyDiff(file, newAtomNames, atoms, keyframes)
217
233
  return { changed: true }
218
234
  }
219
235
 
236
+ /**
237
+ * Merge a file's literal classNames into the union. A literal the
238
+ * union hasn't seen flips `changed` so `writeSchemes` re-emits the
239
+ * scheme files with the new molecule.
240
+ * @param literals Distinct literal className strings.
241
+ * @returns Whether any literal was new to the union.
242
+ */
243
+ private recordLiterals(literals: readonly string[]): boolean {
244
+ let added = false
245
+ for (const literal of literals) {
246
+ if (this.unionLiterals.has(literal)) continue
247
+ this.unionLiterals.add(literal)
248
+ added = true
249
+ }
250
+ return added
251
+ }
252
+
220
253
  /**
221
254
  * Forget one source file's contribution. Idempotent — repeated calls
222
255
  * for a file that's already dropped are no-ops. Does NOT remove the
@@ -246,7 +279,7 @@ class UnionBuilder {
246
279
  public async writeSchemes(): Promise<{ changedSchemes: readonly string[] }> {
247
280
  await this.ensureProjectScanned()
248
281
  const sortedAtomNames = [...this.unionAtoms.keys()].toSorted((a, b) => a.localeCompare(b))
249
- const result = buildSchemeSources(sortedAtomNames, this.unionAtoms, this.unionKeyframes, this.serializedCache, this.breakpoints)
282
+ const result = buildSchemeSources(sortedAtomNames, this.unionAtoms, this.unionKeyframes, this.serializedCache, this.breakpoints, this.unionGradients, this.unionHaptics, [...this.unionLiterals])
250
283
  this.serializedMissesCount += result.serializedMisses
251
284
  const { schemeSources, manifestSource } = result
252
285
 
package/src/metro/dts.ts CHANGED
@@ -56,21 +56,15 @@ const CONTENT_CONTAINER_INTERFACES: ReadonlySet<string> = new Set([
56
56
 
57
57
  /**
58
58
  * Build the body of one interface augmentation: `className?: string`
59
- * plus `<prefix>ClassName?: string` for every prefix that applies to
60
- * THIS interface. Emits a single-line body so the file stays easy to
61
- * scan and diff.
59
+ * plus `contentContainerClassName?: string` for the scroll interfaces
60
+ * that natively expose `contentContainerStyle`. Emits a single-line
61
+ * body so the file stays easy to scan and diff.
62
62
  * @param interfaceName Bare interface name (generic parameters stripped).
63
- * @param userPrefixes Extra prefixes from the Metro config — applied to
64
- * every interface the same way `className` is.
65
63
  * @returns Space-separated property declarations.
66
64
  */
67
- function buildInterfaceBody(interfaceName: string, userPrefixes: readonly string[]): string {
65
+ function buildInterfaceBody(interfaceName: string): string {
68
66
  const props = ['className?: string']
69
67
  if (CONTENT_CONTAINER_INTERFACES.has(interfaceName)) props.push(`${BUILTIN_PREFIX}ClassName?: string`)
70
- for (const prefix of userPrefixes) {
71
- if (prefix === BUILTIN_PREFIX) continue
72
- props.push(`${prefix}ClassName?: string`)
73
- }
74
68
  return props.join('; ')
75
69
  }
76
70
 
@@ -83,17 +77,11 @@ function buildInterfaceBody(interfaceName: string, userPrefixes: readonly string
83
77
  * `Scheme` union so `useScheme()` returns the actual names.
84
78
  *
85
79
  * Called once at Metro-config time — overwrite-on-rewrite so the file
86
- * stays in sync with the user's current theme CSS + prefix config.
80
+ * stays in sync with the user's current theme CSS.
87
81
  * @param targetPath Absolute path to write (typically `rnwind-types.d.ts` at project root).
88
82
  * @param schemes Scheme names from the user's `@variant` blocks (empty when none declared).
89
- * @param classNamePrefixes Extra prefixes from the Metro config — merged
90
- * on top of the built-in `'contentContainer'`. Defaults to empty.
91
83
  */
92
- export function writeDtsFile(
93
- targetPath: string,
94
- schemes: readonly string[],
95
- classNamePrefixes: readonly string[] = [],
96
- ): void {
84
+ export function writeDtsFile(targetPath: string, schemes: readonly string[]): void {
97
85
  const lines: string[] = [
98
86
  '// Auto-generated by rnwind — do not edit by hand.',
99
87
  '// Overwritten on Metro start / theme CSS change.',
@@ -102,7 +90,7 @@ export function writeDtsFile(
102
90
  ]
103
91
  for (const entry of INTERFACES) {
104
92
  const name = typeof entry === 'string' ? entry : entry.name
105
- const body = buildInterfaceBody(name, classNamePrefixes)
93
+ const body = buildInterfaceBody(name)
106
94
  if (typeof entry === 'string') {
107
95
  lines.push(` interface ${entry} { ${body} }`)
108
96
  continue