@unrulysystems/native-motion 0.1.0-alpha.0

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 (117) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +55 -0
  4. package/android/build.gradle +24 -0
  5. package/android/src/main/AndroidManifest.xml +1 -0
  6. package/android/src/main/java/com/unrulysystems/nativemotion/GestureExclusionModule.kt +55 -0
  7. package/android/src/main/java/com/unrulysystems/nativemotion/NativeMotionPackage.kt +27 -0
  8. package/package.json +57 -0
  9. package/react-native.config.js +14 -0
  10. package/src/native/driver/instantWindow.ts +31 -0
  11. package/src/native/driver/strictModeReplay.ts +20 -0
  12. package/src/native/driver/uiLayoutEngine.ts +845 -0
  13. package/src/native/driver/uiLayoutGraph.ts +338 -0
  14. package/src/native/driver/uiValueChannel.ts +4640 -0
  15. package/src/native/driver/workletDriver.ts +3860 -0
  16. package/src/native/motion/AnimatePresence.tsx +1605 -0
  17. package/src/native/motion/LayoutGroup.tsx +163 -0
  18. package/src/native/motion/MotionConfig.tsx +400 -0
  19. package/src/native/motion/MotionRoot.tsx +250 -0
  20. package/src/native/motion/MotionView.tsx +6761 -0
  21. package/src/native/motion/addScaleCorrector.ts +97 -0
  22. package/src/native/motion/colorEndpointUiFeed.ts +32 -0
  23. package/src/native/motion/colorProjection.ts +39 -0
  24. package/src/native/motion/composeTransform.ts +271 -0
  25. package/src/native/motion/constraintMeasure.ts +157 -0
  26. package/src/native/motion/deferredPendingSnapshots.ts +25 -0
  27. package/src/native/motion/discreteProjection.ts +36 -0
  28. package/src/native/motion/dragAncestorPanContext.ts +28 -0
  29. package/src/native/motion/dragControls.ts +152 -0
  30. package/src/native/motion/dragGestureWiring.ts +1168 -0
  31. package/src/native/motion/dragHandoffBinding.ts +179 -0
  32. package/src/native/motion/dragHubStream.ts +756 -0
  33. package/src/native/motion/dragPropagationLock.ts +57 -0
  34. package/src/native/motion/driverValueChannel.ts +3288 -0
  35. package/src/native/motion/externalDragDriver.ts +214 -0
  36. package/src/native/motion/frameData.ts +31 -0
  37. package/src/native/motion/gestureBinding.ts +107 -0
  38. package/src/native/motion/gestureStateGate.ts +563 -0
  39. package/src/native/motion/gestureStateResolver.ts +286 -0
  40. package/src/native/motion/identityValueChannelControllerAdapter.ts +2043 -0
  41. package/src/native/motion/identityValueChannelControllerReconciliation.ts +176 -0
  42. package/src/native/motion/identityValueChannelLaneMarker.ts +14 -0
  43. package/src/native/motion/imperativeAnimate.ts +816 -0
  44. package/src/native/motion/keyframeTiming.ts +7 -0
  45. package/src/native/motion/layoutIdBinding.ts +261 -0
  46. package/src/native/motion/layoutIdFlightConfig.ts +51 -0
  47. package/src/native/motion/layoutProjection.ts +89 -0
  48. package/src/native/motion/layoutScroll.ts +204 -0
  49. package/src/native/motion/layoutTransition.ts +1302 -0
  50. package/src/native/motion/lengthLayoutContext.tsx +100 -0
  51. package/src/native/motion/lengthLayoutHost.ts +44 -0
  52. package/src/native/motion/mappedKeys.ts +184 -0
  53. package/src/native/motion/motionViewController.ts +3180 -0
  54. package/src/native/motion/nativeHostMarker.ts +22 -0
  55. package/src/native/motion/panSessionWiring.ts +127 -0
  56. package/src/native/motion/pathTransition.ts +101 -0
  57. package/src/native/motion/popLayout.ts +176 -0
  58. package/src/native/motion/presenceBinding.ts +382 -0
  59. package/src/native/motion/scaleCorrectorRegistry.ts +123 -0
  60. package/src/native/motion/scrollValues.ts +260 -0
  61. package/src/native/motion/serializablePayload.ts +151 -0
  62. package/src/native/motion/severity.ts +16 -0
  63. package/src/native/motion/shippedSurface.ts +843 -0
  64. package/src/native/motion/staticLengthGate.ts +86 -0
  65. package/src/native/motion/styleBaseGate.ts +72 -0
  66. package/src/native/motion/styleValueBinding.ts +657 -0
  67. package/src/native/motion/systemGestureExclusion.ts +61 -0
  68. package/src/native/motion/tapGestureWiring.ts +215 -0
  69. package/src/native/motion/transformOrder.ts +38 -0
  70. package/src/native/motion/transformStringBinding.ts +200 -0
  71. package/src/native/motion/transformTemplateGate.ts +45 -0
  72. package/src/native/motion/transitionGate.ts +435 -0
  73. package/src/native/motion/useAnimate.ts +52 -0
  74. package/src/native/motion/useCycle.ts +44 -0
  75. package/src/native/motion/useInstantTransition.ts +68 -0
  76. package/src/native/motion/useReducedMotion.ts +58 -0
  77. package/src/native/motion/useScroll.ts +162 -0
  78. package/src/native/motion/useViewportScroll.ts +29 -0
  79. package/src/native/motion/valueChannel.ts +7121 -0
  80. package/src/native/motion/valueHooks.ts +901 -0
  81. package/src/native/motion/variantChildRegistry.ts +67 -0
  82. package/src/native/motion/variantContext.tsx +172 -0
  83. package/src/native/motion/variantProps.ts +598 -0
  84. package/src/native.ts +159 -0
  85. package/src/verification/harnessMetrics.ts +78 -0
  86. package/src/verification/probe/LayoutIdentityWorkletProbe.tsx +251 -0
  87. package/src/verification/probe/WorkletParityProbe.tsx +162 -0
  88. package/src/verification/probe/layoutIdentityProbeEngine.ts +429 -0
  89. package/src/verification/screens/ArcPathChecksScreen.tsx +273 -0
  90. package/src/verification/screens/BooleanAnimateChecksScreen.tsx +358 -0
  91. package/src/verification/screens/ChoreographyGalleryScreen.tsx +1010 -0
  92. package/src/verification/screens/ColorBindingChecksScreen.tsx +568 -0
  93. package/src/verification/screens/CompletionChecksScreen.tsx +294 -0
  94. package/src/verification/screens/ConformanceScreen.tsx +573 -0
  95. package/src/verification/screens/ContentionProbeScreen.tsx +224 -0
  96. package/src/verification/screens/DriverSmokeScreen.tsx +99 -0
  97. package/src/verification/screens/DurationOnlyTweenChecksScreen.tsx +294 -0
  98. package/src/verification/screens/DynamicDragConfigChecksScreen.tsx +237 -0
  99. package/src/verification/screens/FrameDataChecksScreen.tsx +252 -0
  100. package/src/verification/screens/GestureChecksScreen.tsx +685 -0
  101. package/src/verification/screens/InstantTransitionChecksScreen.tsx +839 -0
  102. package/src/verification/screens/LayoutAnimationStartChecksScreen.tsx +648 -0
  103. package/src/verification/screens/LayoutChecksScreen.tsx +822 -0
  104. package/src/verification/screens/LayoutCommitSpikeScreen.tsx +142 -0
  105. package/src/verification/screens/MotionViewChecksScreen.tsx +930 -0
  106. package/src/verification/screens/PresenceChecksScreen.tsx +614 -0
  107. package/src/verification/screens/ReducedMotionChecksScreen.tsx +614 -0
  108. package/src/verification/screens/RestThresholdChecksScreen.tsx +634 -0
  109. package/src/verification/screens/ScaleCorrectorChecksScreen.tsx +425 -0
  110. package/src/verification/screens/SharedLayoutContinuityChecksScreen.tsx +338 -0
  111. package/src/verification/screens/SharedLayoutCrossfadeChecksScreen.tsx +425 -0
  112. package/src/verification/screens/TransitionDefaultSelectionChecksScreen.tsx +514 -0
  113. package/src/verification/screens/ViewportScrollAliasChecksScreen.tsx +303 -0
  114. package/src/verification/screens/conformanceBanner.ts +21 -0
  115. package/src/verification/screens/proofConsoleTap.ts +18 -0
  116. package/src/verification.ts +47 -0
  117. package/src/web.ts +98 -0
@@ -0,0 +1,657 @@
1
+ // Style-bound public MotionValues (REQ-API-026 / REQ-DRIVER-029): a bound value's DIRECT writes
2
+ // commit through the driver's binding lane — one crossing per set(), the same event-shaped
3
+ // cadence gesture writes ride, never a frame loop. Unlike a gesture write, a direct set settles
4
+ // immediately and cannot manufacture a finger hold. Review r1 major 1 law: a bound
5
+ // value REFUSES animation-driven traffic — a JS-side animation would cross the bridge every
6
+ // frame (the stall the Elsewhere stress toggle exists to expose), so `animationStart` on a
7
+ // bound value fails under the severity law naming the driver-derived-channel successor rung.
8
+ // Two string-binding classes join the numeric one (REQ-API-026): U5's rgba class (COLOR_KEY_NAMES,
9
+ // mappedKeys.ts) binds a MotionValue<string> and publishes through the settled-color pattern —
10
+ // a fresh `[c, c]` endpoint sequence at PROGRESS_END through the controller's onColorEndpoints
11
+ // port (packet F3), the same delivery static authored colors use; U5 W3's `transform` key binds a
12
+ // MotionValue<string> parsed per set() with the pinned transform-function grammar
13
+ // (transformStringBinding.ts) and written through the SAME numeric retarget lane as ONE multi-key
14
+ // map (parsed components at their values, absent components reset to their pin defaults — the
15
+ // pin's imperative `element.style.transform = str` replaces the whole transform,
16
+ // motion-dom build-styles.ts:52-66). A bound color is validated at
17
+ // split AND per set() (core's strict isColor + parseColor validator, REQ-VALUETYPE-008
18
+ // lineage): the pin binds any string verbatim (motion@12.42.2 scrape-motion-values.ts:6-29),
19
+ // native keeps its standing loud policy. A bound transform string is validated the same way —
20
+ // the pinned-grammar parse refuses loud naming the key and the unsupported form. General
21
+ // string/unit binding beyond the rgba class and `transform` stays deferred with the same loud,
22
+ // named failure.
23
+
24
+ import {
25
+ describeValue,
26
+ isColor,
27
+ parseColor,
28
+ type ElementHandle,
29
+ type MotionValue,
30
+ type RGBA,
31
+ } from '@unrulysystems/native-motion-core'
32
+ import { isDirectPublicNumericMotionValue } from '@unrulysystems/native-motion-core/internal-driver'
33
+ import type { WorkletDriverBinding } from '../driver/workletDriver'
34
+ import {
35
+ COLOR_KEY_NAMES,
36
+ NUMERIC_STYLE_KEY_NAMES,
37
+ TRANSFORM_KEY_NAMES,
38
+ TRANSFORM_STRING_KEY_NAME,
39
+ } from './mappedKeys'
40
+ import { NATIVE_TRANSFORM_ORDER } from './transformOrder'
41
+ import {
42
+ isNativeTransformComponentKey,
43
+ parseTransformStringComponents,
44
+ transformStringWriteMap,
45
+ } from './transformStringBinding'
46
+
47
+ // The rgba-class binding set (U5): the literal class array as a membership probe. mappedKeys is
48
+ // data-only (no RN imports), and its registry cross-check test binds the class to the shipped
49
+ // color keys — the same set the controller's isColorKey classifies through the registry.
50
+ const COLOR_BINDABLE_KEYS: ReadonlySet<string> = new Set<string>(COLOR_KEY_NAMES)
51
+ const IDENTITY_CHANNEL_NUMERIC_KEYS: ReadonlySet<string> = new Set<string>([
52
+ ...TRANSFORM_KEY_NAMES,
53
+ ...NUMERIC_STYLE_KEY_NAMES,
54
+ ])
55
+
56
+ /** A bound entry's value: numeric on the numeric-natured keys, a color string on the rgba class. */
57
+ export type BoundStyleValue = MotionValue<number> | MotionValue<string>
58
+
59
+ /**
60
+ * Shape hint shared by full public MotionValues and partial duck-types. Objects with get +
61
+ * subscribe but a missing runtime surface must never be treated as ordinary statics (they would
62
+ * reach the RN host bag) or as full MotionValues (attach would TypeError) — family-5 major
63
+ * 285dacf76bfe.
64
+ */
65
+ export function hasMotionValueShapeHints(candidate: unknown): boolean {
66
+ return (
67
+ typeof candidate === 'object' &&
68
+ candidate !== null &&
69
+ typeof (candidate as { get?: unknown }).get === 'function' &&
70
+ typeof (candidate as { subscribe?: unknown }).subscribe === 'function'
71
+ )
72
+ }
73
+
74
+ /**
75
+ * Exact public MotionValue duck-type used by every style / transform binding authority.
76
+ * Requires the full runtime surface the binding attach path immediately invokes (get/set/jump/
77
+ * subscribe/getVelocity/on/stop/isAnimating/destroy). Partial objects are refused separately.
78
+ */
79
+ export function isMotionValue(candidate: unknown): candidate is MotionValue<unknown> {
80
+ if (!hasMotionValueShapeHints(candidate)) return false
81
+ const value = candidate as MotionValue<unknown>
82
+ return (
83
+ typeof value.set === 'function' &&
84
+ typeof value.jump === 'function' &&
85
+ typeof value.getVelocity === 'function' &&
86
+ typeof value.on === 'function' &&
87
+ typeof value.stop === 'function' &&
88
+ typeof value.isAnimating === 'function' &&
89
+ typeof value.destroy === 'function'
90
+ )
91
+ }
92
+
93
+ /** Partial duck-type: MotionValue-shaped hints without the full public surface. */
94
+ export function isNearMotionValue(candidate: unknown): boolean {
95
+ return hasMotionValueShapeHints(candidate) && !isMotionValue(candidate)
96
+ }
97
+
98
+ export interface StyleValueGate {
99
+ readonly severity: 'development' | 'production'
100
+ readonly report: (error: Error) => void
101
+ /**
102
+ * Public host identity for errors emitted by the shared style-binding lanes.
103
+ * Required — a silent Motion.View default would launder Text refusals as View (R9 M2 r15).
104
+ */
105
+ readonly componentId: '<Motion.View>' | '<Motion.Text>' | '<Motion.Image>'
106
+ }
107
+
108
+ const THROW_GATE: StyleValueGate = {
109
+ severity: 'development',
110
+ report: () => {},
111
+ componentId: '<Motion.View>',
112
+ }
113
+
114
+ const componentPrefix = (gate: StyleValueGate): string => `${gate.componentId}: `
115
+
116
+ export interface SplitBoundStyle {
117
+ /** Style entries whose value is a public MotionValue, keyed by style key. */
118
+ readonly bound: Record<string, BoundStyleValue>
119
+ /** The one scalar snapshot captured while admitting each bound entry. */
120
+ readonly resolved: Record<string, number | string>
121
+ /** The remaining plain entries, untouched (the existing static-style seeding path). */
122
+ readonly statics: Record<string, unknown>
123
+ }
124
+
125
+ const motionViewAuthoredStyleMountPreparationBrand: unique symbol = Symbol(
126
+ 'native-motion.motion-view-authored-style-mount-preparation',
127
+ )
128
+
129
+ /**
130
+ * Opaque result of the authored-style split MotionView runs during mount. Identity-channel
131
+ * adapters consume this object instead of accepting a second caller-authored key/value map, so
132
+ * the value identity registered with the driver is necessarily the one extracted from `style`.
133
+ */
134
+ export interface MotionViewAuthoredStyleMountPreparation {
135
+ readonly [motionViewAuthoredStyleMountPreparationBrand]: true
136
+ readonly boundStyleValues: Record<string, BoundStyleValue>
137
+ readonly resolvedBoundStyleValues: Record<string, number | string>
138
+ readonly staticStyleValues: Record<string, unknown>
139
+ }
140
+
141
+ /**
142
+ * The exact native identity-channel admission class. The caller supplies the already captured
143
+ * scalar so this predicate never performs a collector-visible public read.
144
+ */
145
+ export function isIdentityChannelEligibleBoundStyleValue(
146
+ key: string,
147
+ value: MotionValue<unknown>,
148
+ resolvedCurrentValue: unknown,
149
+ ): value is MotionValue<number> {
150
+ return (
151
+ IDENTITY_CHANNEL_NUMERIC_KEYS.has(key) &&
152
+ !value.isAnimating() &&
153
+ isDirectPublicNumericMotionValue(value, resolvedCurrentValue)
154
+ )
155
+ }
156
+
157
+ export interface IdentityChannelEligibleBoundStyleSnapshot {
158
+ readonly keys: readonly string[]
159
+ readonly resolvedStyleValues: Readonly<Record<string, number>>
160
+ }
161
+
162
+ /** Selects exact eligible bindings from the mount snapshot without re-reading any MotionValue. */
163
+ export function identityChannelEligibleBoundStyleSnapshot(
164
+ preparation: MotionViewAuthoredStyleMountPreparation,
165
+ ): IdentityChannelEligibleBoundStyleSnapshot {
166
+ const keys: string[] = []
167
+ const resolvedStyleValues: Record<string, number> = {}
168
+ for (const [key, value] of Object.entries(preparation.boundStyleValues)) {
169
+ const resolved = preparation.resolvedBoundStyleValues[key]
170
+ if (!isIdentityChannelEligibleBoundStyleValue(key, value, resolved)) continue
171
+ if (typeof resolved !== 'number') continue
172
+ keys.push(key)
173
+ resolvedStyleValues[key] = resolved
174
+ }
175
+ return { keys, resolvedStyleValues }
176
+ }
177
+
178
+ // The bound-color verdict (U5): a color-bound value must be a string core's strict color
179
+ // validator accepts — anything else refuses loud per the severity law, naming the key (the
180
+ // controller's color lane treats an unparseable color target the same way,
181
+ // motionViewController.ts:1075-1087). The barrel-legal pair does the validation: `isColor` is the
182
+ // TOTAL shape gate (named/hex/rgb/hsl), `parseColor` the strict validator — together they are
183
+ // exactly core's parseColorVerdict, whose verdict form is core-internal by the ratified
184
+ // value-types barrel law (value-types/index.ts). Returns the parsed RGBA, or null after a
185
+ // production report (the write is refused; the host keeps the last good value).
186
+ function boundColorValue(key: string, value: unknown, gate: StyleValueGate): RGBA | null {
187
+ let parsed: RGBA | null = null
188
+ let reason: string | null = null
189
+ if (typeof value === 'string') {
190
+ if (!isColor(value)) {
191
+ reason = `Cannot parse ${JSON.stringify(value)} as a color`
192
+ } else {
193
+ try {
194
+ parsed = parseColor(value)
195
+ } catch (error) {
196
+ // The shape gate passed, so a throw here is the strict validator's OWN refusal (a plain
197
+ // Error carrying the refusal message — core's verdict chain never throws anything else).
198
+ // Anything else is a dependency fault and propagates unmasked (M3 r7/r8's no-catch law).
199
+ if (error instanceof Error && error.constructor === Error) reason = error.message
200
+ else throw error
201
+ }
202
+ }
203
+ }
204
+ if (parsed !== null) return parsed
205
+ const error = new Error(
206
+ `${componentPrefix(gate)}style-bound MotionValue on color key '${key}' holds ${describeValue(value)}; ` +
207
+ 'a bound color must be a color string (hex/rgb/hsl/named, REQ-API-026)' +
208
+ `${reason !== null ? ` — ${reason}` : ''}.`,
209
+ )
210
+ if (gate.severity === 'development') throw error
211
+ gate.report(error)
212
+ return null
213
+ }
214
+
215
+ // The bound-transform verdict (U5 W3): a transform-bound value must be a string the pinned-grammar
216
+ // parser accepts (transformStringBinding.ts) — anything else refuses loud per the severity law,
217
+ // naming the key and the unsupported form (the pin hands the string to the browser, which
218
+ // silently drops invalid CSS; native keeps its standing loud policy — declared refuse-class
219
+ // debt, REQ-API-026). Returns the FULL 11-component write map (parsed values; absent components
220
+ // reset to their pin defaults — the pin's imperative replace drops them), or null after a
221
+ // production report (the write is refused; the host keeps the last good components).
222
+ function boundTransformWriteMap(
223
+ value: unknown,
224
+ gate: StyleValueGate,
225
+ ): Record<string, number> | null {
226
+ let map: Record<string, number> | null = null
227
+ let reason: string | null = null
228
+ if (typeof value === 'string') {
229
+ try {
230
+ map = transformStringWriteMap(parseTransformStringComponents(value))
231
+ } catch (error) {
232
+ // The parser throws only plain Errors carrying the refusal reason; anything else is a
233
+ // dependency fault and propagates unmasked (M3 r7/r8's no-catch law).
234
+ if (error instanceof Error && error.constructor === Error) reason = error.message
235
+ else throw error
236
+ }
237
+ }
238
+ if (map !== null) return map
239
+ const error = new Error(
240
+ `${componentPrefix(gate)}style-bound MotionValue on 'transform' holds ${describeValue(value)}; ` +
241
+ 'a bound transform must be a transform-function string in the pinned grammar (the pin ' +
242
+ 'buildTransform emit set plus translate/translate3d, REQ-API-026, U5 W3)' +
243
+ `${reason !== null ? ` — ${reason}` : ''}.`,
244
+ )
245
+ if (gate.severity === 'development') throw error
246
+ gate.report(error)
247
+ return null
248
+ }
249
+
250
+ // The near-MotionValue partial duck-type refusal — identical at split and reconcile: never a
251
+ // static host residual, never an attach TypeError.
252
+ function refuseNearMotionValue(key: string, gate: StyleValueGate): void {
253
+ const error = new Error(
254
+ `${componentPrefix(gate)}style value on '${key}' is a partial MotionValue duck-type ` +
255
+ `(missing full public surface set/jump/on/stop/isAnimating/destroy/getVelocity); ` +
256
+ 'refused under the severity law — supply a real MotionValue or a plain static.',
257
+ )
258
+ if (gate.severity === 'development') throw error
259
+ gate.report(error)
260
+ }
261
+
262
+ // Per-entry VALUE validation against the key's binding class (REQ-API-026): numeric-natured keys
263
+ // take a number-holding MotionValue; the rgba class takes a string-holding MotionValue whose
264
+ // current value parses as a color; `transform` takes a string-holding MotionValue whose current
265
+ // value parses with the pinned transform grammar. Development throws; production reports and the
266
+ // entry is refused. Used where a binding is ADMITTED (the mount split, a new/swapped source at
267
+ // reconcile) — never for an already-attached identity, whose per-write lane owns value validity.
268
+ type BoundEntryResolution =
269
+ | { readonly accepted: true; readonly current: number | string }
270
+ | { readonly accepted: false }
271
+
272
+ function resolveBoundEntry(
273
+ key: string,
274
+ value: MotionValue<unknown>,
275
+ gate: StyleValueGate,
276
+ ): BoundEntryResolution {
277
+ const current = value.get()
278
+ if (key === TRANSFORM_STRING_KEY_NAME) {
279
+ // U5 W3: `transform` binds a MotionValue<string> in the pinned grammar — a numeric MV or an
280
+ // out-of-grammar string refuses here (never the unmapped-key gate downstream).
281
+ return boundTransformWriteMap(current, gate) === null
282
+ ? { accepted: false }
283
+ : { accepted: true, current: current as string }
284
+ }
285
+ if (COLOR_BINDABLE_KEYS.has(key)) {
286
+ return boundColorValue(key, current, gate) === null
287
+ ? { accepted: false }
288
+ : { accepted: true, current: current as string }
289
+ }
290
+ if (typeof current !== 'number') {
291
+ const error = new Error(
292
+ `${componentPrefix(gate)}R4 scope: style-bound MotionValue on '${key}' holds ${describeValue(current)}; ` +
293
+ 'numeric keys bind only numeric MotionValues — the rgba class ' +
294
+ '(backgroundColor/color/borderColor) and `transform` bind MotionValue<string> ' +
295
+ '(REQ-API-026, U5); ' +
296
+ 'general string/unit binding stays deferred to the value-breadth rung.',
297
+ )
298
+ if (gate.severity === 'development') throw error
299
+ gate.report(error) // production: refuse the binding, mount the rest
300
+ return { accepted: false }
301
+ }
302
+ return { accepted: true, current }
303
+ }
304
+
305
+ // Splits bound entries out of a flattened style. A bound value outside its key's class fails
306
+ // under the severity law (REQ-API-014 lineage): development throws; production reports and
307
+ // REFUSES the binding (the key falls out entirely — never a garbage host write). The classes
308
+ // (REQ-API-026): numeric-natured keys take a number-holding MotionValue; the rgba class takes a
309
+ // string-holding MotionValue whose current value parses as a color; `transform` takes a
310
+ // string-holding MotionValue whose current value parses with the pinned transform grammar.
311
+ // Near-MotionValue partial
312
+ // duck-types are the same severity refusal — never static host residual, never attach TypeError.
313
+ export function splitBoundStyleValues(
314
+ flattened: Record<string, unknown>,
315
+ gate: StyleValueGate = THROW_GATE,
316
+ ): SplitBoundStyle {
317
+ const bound: Record<string, BoundStyleValue> = {}
318
+ const resolved: Record<string, number | string> = {}
319
+ const statics: Record<string, unknown> = {}
320
+ for (const [key, value] of Object.entries(flattened)) {
321
+ if (isNearMotionValue(value)) {
322
+ refuseNearMotionValue(key, gate)
323
+ continue
324
+ }
325
+ if (!isMotionValue(value)) {
326
+ statics[key] = value
327
+ continue
328
+ }
329
+ const resolution = resolveBoundEntry(key, value, gate)
330
+ if (!resolution.accepted) continue
331
+ bound[key] = value as BoundStyleValue
332
+ resolved[key] = resolution.current
333
+ }
334
+ return { bound, resolved, statics }
335
+ }
336
+
337
+ /**
338
+ * The single authored-style entrance used by MotionView mount preparation and the exact-identity
339
+ * adapter. The wrapper-shaped argument keeps `style`—not a parallel `boundValues` fixture—as the
340
+ * source of truth while reusing the existing validation and severity law unchanged.
341
+ */
342
+ export function prepareMotionViewAuthoredStyleForMount(
343
+ props: { readonly style: Record<string, unknown> },
344
+ gate: StyleValueGate = THROW_GATE,
345
+ ): MotionViewAuthoredStyleMountPreparation {
346
+ const { bound, resolved, statics } = splitBoundStyleValues(props.style, gate)
347
+ return {
348
+ [motionViewAuthoredStyleMountPreparationBrand]: true,
349
+ boundStyleValues: bound,
350
+ resolvedBoundStyleValues: resolved,
351
+ staticStyleValues: statics,
352
+ }
353
+ }
354
+
355
+ /**
356
+ * The RENDER-reconcile counterpart to the mount split (U5 device-proof fix). The reconcile runs
357
+ * after EVERY commit and answers one question: which (key → MotionValue) bindings does the
358
+ * current style declare? It reconciles IDENTITIES, not values:
359
+ *
360
+ * - An identity-UNCHANGED entry (same source already attached) carries over untouched — even
361
+ * while the source holds a value the write lane just refused. Value validity is a per-WRITE
362
+ * concern: the write lane reported the refusal at set() time and kept the last good host
363
+ * value; re-validating here re-reports the same refusal on every render (a flood) and drops
364
+ * the key, detaching the subscription so a later VALID set() never reaches the host. The
365
+ * pin's posture: the refused write is dropped, the binding lives, the next valid set()
366
+ * applies.
367
+ * - A NEW or SWAPPED source is value-validated exactly like the mount split before it may bind
368
+ * (development throws; production reports once and the key stays unbound).
369
+ */
370
+ export function reconcileBoundStyleValues(
371
+ flattened: Record<string, unknown>,
372
+ attached: Readonly<Record<string, BoundStyleValue>> | null,
373
+ gate: StyleValueGate = THROW_GATE,
374
+ ): Record<string, BoundStyleValue> {
375
+ return reconcileBoundStyleValuesWithIdentityEligibility(flattened, attached, [], gate)
376
+ .boundStyleValues
377
+ }
378
+
379
+ export interface BoundStyleIdentityReconciliation {
380
+ readonly boundStyleValues: Record<string, BoundStyleValue>
381
+ readonly identityEligibleBoundKeys: readonly string[]
382
+ /** One-read snapshots for new or swapped bindings; unchanged identities are intentionally absent. */
383
+ readonly resolvedReplacementStyleValues: Readonly<Record<string, number | string>>
384
+ }
385
+
386
+ /**
387
+ * MotionView's render reconciliation. Existing identities inherit their bind-time verdict; a new
388
+ * or swapped source is read once for ordinary binding validation and exact identity eligibility.
389
+ */
390
+ export function reconcileBoundStyleValuesWithIdentityEligibility(
391
+ flattened: Record<string, unknown>,
392
+ attached: Readonly<Record<string, BoundStyleValue>> | null,
393
+ attachedIdentityEligibleBoundKeys: readonly string[],
394
+ gate: StyleValueGate = THROW_GATE,
395
+ ): BoundStyleIdentityReconciliation {
396
+ const bound: Record<string, BoundStyleValue> = {}
397
+ const identityEligibleBoundKeys: string[] = []
398
+ const resolvedReplacementStyleValues: Record<string, number | string> = {}
399
+ for (const [key, value] of Object.entries(flattened)) {
400
+ if (isNearMotionValue(value)) {
401
+ refuseNearMotionValue(key, gate)
402
+ continue
403
+ }
404
+ if (!isMotionValue(value)) continue
405
+ if (attached?.[key] === value) {
406
+ bound[key] = value as BoundStyleValue
407
+ if (attachedIdentityEligibleBoundKeys.includes(key)) identityEligibleBoundKeys.push(key)
408
+ continue
409
+ }
410
+ const resolution = resolveBoundEntry(key, value, gate)
411
+ if (!resolution.accepted) continue
412
+ bound[key] = value as BoundStyleValue
413
+ resolvedReplacementStyleValues[key] = resolution.current
414
+ if (isIdentityChannelEligibleBoundStyleValue(key, value, resolution.current)) {
415
+ identityEligibleBoundKeys.push(key)
416
+ }
417
+ }
418
+ return { boundStyleValues: bound, identityEligibleBoundKeys, resolvedReplacementStyleValues }
419
+ }
420
+
421
+ /**
422
+ * The UPDATE lane's XOR gate (review r8 major 270052714f55): the one-authority-per-key law
423
+ * holds through the whole lifecycle, so an animate target arriving on a currently-BOUND key is
424
+ * rejected exactly like the mount-time crossover (development throws; production reports and
425
+ * refuses the key while the rest of the update proceeds). U5 W3: a bound `transform` occupies
426
+ * the WHOLE transform component space (the pin's bound string replaces the component build
427
+ * entirely, motion-dom build-styles.ts:52-66), so an animate target on ANY transform shorthand
428
+ * while `transform` is bound is the same dual-authority violation.
429
+ */
430
+ export function gateUpdateAgainstBindings<T extends Record<string, unknown>>(
431
+ gatedTarget: T,
432
+ boundKeys: readonly string[],
433
+ gate: StyleValueGate = THROW_GATE,
434
+ identityEligibleBoundKeys: readonly string[] = [],
435
+ ): T {
436
+ const transformBound = boundKeys.includes(TRANSFORM_STRING_KEY_NAME)
437
+ for (const key of Object.keys(gatedTarget)) {
438
+ const boundHit = boundKeys.includes(key)
439
+ const transformHit = transformBound && isNativeTransformComponentKey(key)
440
+ if (!boundHit && !transformHit) continue
441
+ if (boundHit && identityEligibleBoundKeys.includes(key)) continue
442
+ const error = new Error(
443
+ boundHit
444
+ ? `${componentPrefix(gate)}R4 scope: '${key}' is style-bound AND received an animate target — a key has ONE ` +
445
+ 'reconciliation authority (REQ-DRIVER-029); drive it through the binding or the ' +
446
+ 'target, never both.'
447
+ : `${componentPrefix(gate)}R4 scope: '${key}' received an animate target while 'transform' is style-bound — ` +
448
+ 'a bound transform occupies the WHOLE transform component space, so one authority ' +
449
+ 'covers every shorthand (REQ-DRIVER-029); drive it through the binding or the ' +
450
+ 'target, never both.',
451
+ )
452
+ if (gate.severity === 'development') throw error
453
+ gate.report(error)
454
+ delete gatedTarget[key]
455
+ }
456
+ return gatedTarget
457
+ }
458
+
459
+ /**
460
+ * Strip EVERY MotionValue-shaped entry from a flattened style (review r8 major 53d5f6c1f50e):
461
+ * the host must never receive a raw MotionValue object — including bindings the severity lane
462
+ * REFUSED (non-numeric on a numeric key, invalid color on an rgba key, unmapped, partial
463
+ * duck-types), which are absent from the accepted set but still present in the authored style.
464
+ * Shape hints alone (get+subscribe) are enough to strip; incomplete surfaces must not reach RN
465
+ * (family-5 major 285dacf76bfe).
466
+ */
467
+ export function stripMotionValueEntries(
468
+ flattened: Record<string, unknown>,
469
+ ): Record<string, unknown> {
470
+ const stripped: Record<string, unknown> = {}
471
+ for (const [key, value] of Object.entries(flattened)) {
472
+ if (!hasMotionValueShapeHints(value)) stripped[key] = value
473
+ }
474
+ return stripped
475
+ }
476
+
477
+ /**
478
+ * The per-render bound reconcile gate (r15 major 2f0c634bd8e1): the bindable set is the
479
+ * BOUND-AT-MOUNT key set — never the full mounted key set. A key mounted declaratively already
480
+ * has a driver registration; accepting a later binding on it would put two authorities on one
481
+ * key (the old registration and the new binding coexist). Identity swaps/removals within the
482
+ * mounted bound set pass; a key currently targeted by animate is the r8 crossover refusal.
483
+ * Returns the accepted bound map (production drops refused keys; development throws).
484
+ */
485
+ export function gateRenderBoundKeys(
486
+ renderBound: Record<string, BoundStyleValue>,
487
+ mount: {
488
+ readonly mountedBoundKeys: readonly string[]
489
+ readonly animateKeys: readonly string[]
490
+ readonly identityEligibleBoundKeys?: readonly string[]
491
+ /** Exact direct-numeric bindings admitted by an empty-controller replacement mount. */
492
+ readonly identityEligibleGrowBoundKeys?: readonly string[]
493
+ },
494
+ gate: StyleValueGate = THROW_GATE,
495
+ ): Record<string, BoundStyleValue> {
496
+ for (const key of Object.keys(renderBound)) {
497
+ // Both crossover directions are refused (r8): a binding appearing on a key the animate
498
+ // prop currently targets is the same dual-authority violation as the reverse. U5 W3: a
499
+ // bound `transform` occupies the WHOLE transform component space, so an animate target on
500
+ // any transform shorthand crosses it.
501
+ const animateCrossover = mount.animateKeys.some(
502
+ (animateKey) =>
503
+ animateKey === key ||
504
+ (key === TRANSFORM_STRING_KEY_NAME && isNativeTransformComponentKey(animateKey)),
505
+ )
506
+ if (animateCrossover && mount.identityEligibleBoundKeys?.includes(key) !== true) {
507
+ const error = new Error(
508
+ key === TRANSFORM_STRING_KEY_NAME
509
+ ? `${componentPrefix(gate)}R4 scope: 'transform' is style-bound AND a declarative target names a transform ` +
510
+ 'shorthand — a bound transform occupies the WHOLE transform component space, so ' +
511
+ 'one authority covers every shorthand (REQ-DRIVER-029).'
512
+ : `${componentPrefix(gate)}R4 scope: '${key}' is a declarative target AND became style-bound — a key has ONE ` +
513
+ 'reconciliation authority (REQ-DRIVER-029).',
514
+ )
515
+ if (gate.severity === 'development') throw error
516
+ gate.report(error)
517
+ delete renderBound[key]
518
+ continue
519
+ }
520
+ if (
521
+ !mount.mountedBoundKeys.includes(key) &&
522
+ mount.identityEligibleGrowBoundKeys?.includes(key) !== true
523
+ ) {
524
+ const error = new Error(
525
+ `${componentPrefix(gate)}R4 scope: style-bound key '${key}' was not BOUND at mount — binding authority is ` +
526
+ 'fixed at mount per key (a declaratively-mounted key keeps its driver registration; ' +
527
+ 'a late binding would be a second authority — REQ-API-026/REQ-DRIVER-029).',
528
+ )
529
+ if (gate.severity === 'development') throw error
530
+ gate.report(error)
531
+ delete renderBound[key]
532
+ }
533
+ }
534
+ return renderBound
535
+ }
536
+
537
+ /**
538
+ * The bound-key set as an OCCUPANCY list for gates that compare target key names
539
+ * (gateGestureStateProps): a bound `transform` occupies every transform shorthand
540
+ * (U5 W3, REQ-DRIVER-029) — the gesture-state XOR must see the whole component space, not just
541
+ * the literal bound key.
542
+ */
543
+ export function expandBoundAuthorityKeys(boundKeys: readonly string[]): readonly string[] {
544
+ if (!boundKeys.includes(TRANSFORM_STRING_KEY_NAME)) return boundKeys
545
+ return [...boundKeys, ...NATIVE_TRANSFORM_ORDER]
546
+ }
547
+
548
+ export interface StyleValueBinding {
549
+ /**
550
+ * Subscribe the bound values and materialize their current values through the lane. By
551
+ * default every key pushes; the rebind lane passes ONLY the swapped keys (r12 minor
552
+ * e14a9c72f6b3 — unchanged keys already sit at their live value on the host, so a rebind
553
+ * writes exactly one crossing per changed key).
554
+ */
555
+ attach(pushKeys?: readonly string[]): void
556
+ detach(): void
557
+ }
558
+
559
+ export function createStyleValueBinding(
560
+ binding: WorkletDriverBinding,
561
+ handle: ElementHandle,
562
+ bound: Readonly<Record<string, BoundStyleValue>>,
563
+ gate: StyleValueGate = THROW_GATE,
564
+ // U5 (packet F3): the rgba-class delivery port — MotionView's onColorEndpoints hook, the SAME
565
+ // port the controller's static-color path feeds (MotionView.tsx:3542). A bound color publishes
566
+ // a fresh settled `[c, c]` sequence per write; the key's driver progress rests at PROGRESS_END
567
+ // from mount (addStaticKeys), so no projection machinery and no numeric lane crossing exists.
568
+ onColorEndpoints?: (key: string, sequence: readonly RGBA[]) => void,
569
+ ): StyleValueBinding {
570
+ let unsubscribes: Array<() => void> | null = null
571
+ // The animation-refusal law (review r1 major 1): an animation driving a bound value is
572
+ // per-frame JS→UI traffic. Fail loud naming the successor rung; production reports and
573
+ // stops the offending animation (fail-closed — the element keeps its last written value).
574
+ const refuseAnimation = (key: string, value: BoundStyleValue): void => {
575
+ const error = new Error(
576
+ `${componentPrefix(gate)}R4 scope: style-bound MotionValue on '${key}' is being driven by a JS-side ` +
577
+ 'animation — per-frame bridge traffic is refused (REQ-DRIVER-029). Animate the ' +
578
+ 'element property directly (driver-side), or wait for the driver-derived channel ' +
579
+ 'rung (specs/R4-VALUE-CHANNEL-BUILD-PACKET.md).',
580
+ )
581
+ value.stop()
582
+ if (gate.severity === 'development') throw error
583
+ gate.report(error)
584
+ }
585
+ // One delivery writer per bound entry (U5): rgba-class entries validate the color string and
586
+ // publish the settled sequence; U5 W3's `transform` entry parses the string with the pinned
587
+ // grammar and writes the FULL component map through the SAME numeric retarget lane (one
588
+ // multi-key write per set() — uiApplyWrite validates all keys before mutating any,
589
+ // workletDriver.ts:1501-1518); numeric entries validate per write too (U5 review round 1
590
+ // MAJOR 1 — the render reconcile no longer re-validates held values after 8be94db5, so this
591
+ // arm is the ONLY checkpoint a non-number set() on an attached numeric binding meets:
592
+ // development throws, production reports and keeps the last good write). A color key without
593
+ // the port is a wiring invariant violation (the component always threads it) — loud, never a
594
+ // silent drop.
595
+ const writerFor = (key: string): ((latest: unknown) => void) => {
596
+ if (key === TRANSFORM_STRING_KEY_NAME) {
597
+ return (latest) => {
598
+ const map = boundTransformWriteMap(latest, gate)
599
+ if (map === null) return // production: refuse the write, keep the last good components
600
+ binding.driver.write(handle, map, undefined, 'bound-value-retarget')
601
+ }
602
+ }
603
+ if (!COLOR_BINDABLE_KEYS.has(key)) {
604
+ return (latest) => {
605
+ if (typeof latest !== 'number') {
606
+ const error = new Error(
607
+ `${componentPrefix(gate)}style-bound MotionValue on '${key}' holds ${describeValue(latest)}; ` +
608
+ 'a bound numeric key writes only numbers — the write is refused under the ' +
609
+ 'severity law (REQ-API-026, REQ-DRIVER-029).',
610
+ )
611
+ if (gate.severity === 'development') throw error
612
+ gate.report(error)
613
+ return // production: refuse the write, keep the last good value
614
+ }
615
+ binding.driver.write(handle, { [key]: latest }, undefined, 'bound-value-retarget')
616
+ }
617
+ }
618
+ if (onColorEndpoints === undefined) {
619
+ throw new Error(
620
+ `${componentPrefix(gate)}style-bound color key '${key}' has no onColorEndpoints delivery ` +
621
+ 'port — the MotionView wiring invariant is broken (U5, REQ-API-026).',
622
+ )
623
+ }
624
+ const publish = onColorEndpoints
625
+ return (latest) => {
626
+ const parsed = boundColorValue(key, latest, gate)
627
+ if (parsed === null) return // production: refuse the write, keep the last good value
628
+ publish(key, [parsed, parsed]) // settled: from = to (constant projection), packet F3
629
+ }
630
+ }
631
+ return {
632
+ attach(pushKeys?: readonly string[]): void {
633
+ if (unsubscribes !== null) return
634
+ for (const [key, value] of Object.entries(bound)) {
635
+ // A value ALREADY animating at attach gets the same refusal as one that starts while
636
+ // bound (r12 major 4f2c9b7a1e06): a late mount or source swap must not open a
637
+ // per-frame channel the animationStart listener can never observe.
638
+ if (value.isAnimating()) refuseAnimation(key, value)
639
+ // Materialize the CURRENT value at attach (r11): a set() that landed before attachment
640
+ // (the layout-effect window) must reach the host — the subscription alone would wait
641
+ // for the NEXT change and leave the element stale.
642
+ if (pushKeys === undefined || pushKeys.includes(key)) {
643
+ writerFor(key)(value.get())
644
+ }
645
+ }
646
+ unsubscribes = Object.entries(bound).flatMap(([key, value]) => [
647
+ value.subscribe(writerFor(key)),
648
+ value.on('animationStart', () => refuseAnimation(key, value)),
649
+ ])
650
+ },
651
+ detach(): void {
652
+ if (unsubscribes === null) return
653
+ for (const unsubscribe of unsubscribes) unsubscribe()
654
+ unsubscribes = null
655
+ },
656
+ }
657
+ }