@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,435 @@
1
+ // The ELEMENT transition boundary (r7 major 41b089c8c0af; lane law r9 762e9d0a4c77,
2
+ // consumer-set law r11 3a1b5478e6c2; T18-b REQ-API-050): the `transition` prop feeds every
3
+ // animation consumer the element mounts, and ONE authored config must execute IDENTICALLY on
4
+ // all of them (the web engine applies it whole). It gates once here: core validateTransition
5
+ // (shape, vocabulary, ranges, derived-config probe) plus the native option laws — `velocity` is
6
+ // executed by BOTH native lanes since T21 F3 (property spring seeding, REQ-SPRING-007; the
7
+ // layout flight's toSpringConfig) and refuses only on a layoutId flight (spring physics,
8
+ // family-3); `delay` is executable by the layout builder AND
9
+ // by the property lane (T18-b taught it the REQ-TIMING-004 rebase), so any element with a
10
+ // layout or property consumer accepts it and both lanes delay identically. A layoutId flight
11
+ // still refuses it — our FLIP machinery never reads the element transition's delay while the
12
+ // pin passes transition options into projection flights: a recorded divergence, demand-gated
13
+ // (T18-b L5). A WHOLLY consumer-less element stores the shape-validated config DORMANT
14
+ // (T23 D): the pin consumes the element transition lazily (getDefaultTransition), so nothing
15
+ // executes and nothing is silently inert — G-INV-8 is scoped to elements with at least one
16
+ // mounted consumer that cannot execute the option; a consumer mounting on a later render
17
+ // re-gates the same snapshot with real consumers.
18
+
19
+ import {
20
+ InvalidTransitionError,
21
+ captureTransition,
22
+ TARGET_PROPERTY_KEYS,
23
+ validateTransitionRefusal,
24
+ type Transition,
25
+ } from '@unrulysystems/native-motion-core'
26
+ import {
27
+ keyframeTimingRefusal,
28
+ resolveTransitionForKey,
29
+ } from '@unrulysystems/native-motion-core/internal-driver'
30
+ import type { StyleValueGate } from './styleValueBinding'
31
+ import type { MotionComponentId } from './motionViewController'
32
+
33
+ const capturedElementTransitions = new WeakSet<object>()
34
+ const genericallyValidatedElementTransitions = new WeakSet<object>()
35
+ const TRANSITION_MAP_KEYS = new Set<string>(['default', 'layout', ...TARGET_PROPERTY_KEYS])
36
+
37
+ function hasTransitionMap(transition: Transition): boolean {
38
+ return Reflect.ownKeys(transition).some(
39
+ (key) => typeof key === 'string' && TRANSITION_MAP_KEYS.has(key),
40
+ )
41
+ }
42
+
43
+ function transitionMapSourceKey(transition: Transition, key: string): string {
44
+ const source = transition as Record<PropertyKey, unknown>
45
+ if (
46
+ TRANSITION_MAP_KEYS.has(key) &&
47
+ Object.hasOwn(source, key) &&
48
+ source[key] !== undefined &&
49
+ source[key] !== null
50
+ )
51
+ return key
52
+ if (Object.hasOwn(source, 'default') && source.default !== undefined && source.default !== null)
53
+ return 'default'
54
+ return 'root'
55
+ }
56
+
57
+ function sanitizedTransitionMap(
58
+ transition: Transition,
59
+ invalidSources: ReadonlySet<string>,
60
+ ): Transition | undefined {
61
+ // Removing a selected source changes the resolver's fallback path. Unless every newly exposed
62
+ // fallback has been gated for the same mounted consumer, preserving the map would let an invalid
63
+ // root/default option cross the production boundary without a report. Refuse the map atomically.
64
+ if (invalidSources.size > 0) return undefined
65
+ const source = transition as Record<PropertyKey, unknown>
66
+ const out: Record<PropertyKey, unknown> = Object.create(null)
67
+ const keepRoot = !invalidSources.has('root')
68
+ for (const key of Reflect.ownKeys(source)) {
69
+ if (typeof key === 'string' && TRANSITION_MAP_KEYS.has(key)) {
70
+ if (!invalidSources.has(key)) {
71
+ const descriptor = Object.getOwnPropertyDescriptor(source, key)
72
+ if (descriptor !== undefined)
73
+ Object.defineProperty(out, key, {
74
+ value: descriptor.value,
75
+ enumerable: descriptor.enumerable === true,
76
+ configurable: true,
77
+ writable: true,
78
+ })
79
+ }
80
+ continue
81
+ }
82
+ if (!keepRoot) continue
83
+ const descriptor = Object.getOwnPropertyDescriptor(source, key)
84
+ if (descriptor !== undefined)
85
+ Object.defineProperty(out, key, {
86
+ value: descriptor.value,
87
+ enumerable: descriptor.enumerable === true,
88
+ configurable: true,
89
+ writable: true,
90
+ })
91
+ }
92
+ return Reflect.ownKeys(out).length === 0 ? undefined : (Object.freeze(out) as Transition)
93
+ }
94
+
95
+ // A plain-object transition can be captured into a frozen snapshot; a malformed SHAPE (primitive, array,
96
+ // prototype-carrying) must stay raw so core's validateTransition throws its typed refusal rather than a
97
+ // capture laundering it into an empty `{}`.
98
+ function isPlainTransition(value: Transition): boolean {
99
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
100
+ const proto = Object.getPrototypeOf(value) as unknown
101
+ return proto === Object.prototype || proto === null
102
+ }
103
+
104
+ // Capture the authored element transition before more than one supplying boundary needs it. Motion.View
105
+ // passes this ONE snapshot to the render-time gesture gate, the element consumer-set gate, and the
106
+ // mount-time gesture gate, so validation and execution never observe different caller-owned truths.
107
+ export function captureElementTransition(value: Transition | undefined): Transition | undefined {
108
+ if (value === undefined) return undefined
109
+ if (!isPlainTransition(value)) return value
110
+ // Motion.View has already captured this snapshot for an earlier supplying boundary. Preserve identity
111
+ // so the gesture gate validates exactly the transition later handed to layout/property consumers.
112
+ if (capturedElementTransitions.has(value)) return value
113
+ const snapshot = captureTransition(value)
114
+ capturedElementTransitions.add(snapshot)
115
+ return snapshot
116
+ }
117
+
118
+ // Settle the generic core transition contract before a supplied snapshot can become an effective
119
+ // fallback for another surface. Gesture-state targets inherit the element transition, so passing a
120
+ // malformed raw value into their keyframe predicate would make an implementation detail (`.times`)
121
+ // throw before the established typed transition refusal. Successful snapshots are remembered only to
122
+ // avoid rerunning the pure validation when the consumer-set gate receives this exact captured truth.
123
+ export function gateElementTransitionShape(
124
+ value: Transition | undefined,
125
+ gate: StyleValueGate,
126
+ componentId: MotionComponentId = '<Motion.View>',
127
+ ): Transition | undefined {
128
+ if (value === undefined) return undefined
129
+ // Capture runs OUTSIDE the try (review major 4e229b): a FOREIGN accessor/proxy fault must preserve
130
+ // its identity instead of being reported as an ordinary malformed transition.
131
+ const snapshot = captureElementTransition(value)
132
+ if (snapshot === undefined) return undefined
133
+ if (
134
+ typeof snapshot === 'object' &&
135
+ snapshot !== null &&
136
+ genericallyValidatedElementTransitions.has(snapshot)
137
+ ) {
138
+ return snapshot
139
+ }
140
+ // RETURN-shaped validation is intentional: a returned refusal is the transition boundary's typed
141
+ // input error, while a dependency fault thrown by validation must retain its identity rather than
142
+ // being mistaken for a production-reportable malformed transition.
143
+ const refusal = validateTransitionRefusal(snapshot, { componentId })
144
+ if (refusal !== null) {
145
+ if (gate.severity === 'development') throw refusal
146
+ gate.report(refusal)
147
+ return undefined // production: refuse the malformed prop before any fallback consumes it
148
+ }
149
+ if (typeof snapshot === 'object' && snapshot !== null) {
150
+ genericallyValidatedElementTransitions.add(snapshot)
151
+ }
152
+ return snapshot
153
+ }
154
+
155
+ // The repeat-family key this transition authored, or null. `repeat` is named first so the common
156
+ // refusal points at the option the author actually reached for; the companions are still named
157
+ // when they appear alone (core validation refuses that pairing, but this lane law must not depend
158
+ // on the order the two gates run in).
159
+ const REPEAT_FAMILY_KEYS = ['repeat', 'repeatType', 'repeatDelay'] as const
160
+
161
+ function repeatFamilyKey(transition: Transition): (typeof REPEAT_FAMILY_KEYS)[number] | null {
162
+ for (const key of REPEAT_FAMILY_KEYS) {
163
+ if (transition[key] !== undefined) return key
164
+ }
165
+ return null
166
+ }
167
+
168
+ export function gateElementTransition(
169
+ value: Transition | undefined,
170
+ context: {
171
+ readonly consumers: {
172
+ /** True when the layout BUILDER consumes the element transition (layout prop truthy). */
173
+ readonly layout: boolean
174
+ /** True when property commands consume it (animate keys or gesture layers mounted). */
175
+ readonly property: boolean
176
+ /**
177
+ * Family-3: true when layoutId projection flight consumes duration/ease tween timing.
178
+ * Does not execute delay or spring physics options.
179
+ */
180
+ readonly layoutId?: boolean
181
+ /** Concrete property keys let a map resolve only the consumers this element actually owns. */
182
+ readonly propertyKeys?: readonly string[]
183
+ }
184
+ },
185
+ gate: StyleValueGate,
186
+ componentId: MotionComponentId = '<Motion.View>',
187
+ ): Transition | undefined {
188
+ // This second stage applies only the native consumer-set law. Generic shape/vocabulary/range
189
+ // validation was already settled by `gateElementTransitionShape` before any gesture can inherit the
190
+ // transition; direct callers still receive that authority through this wrapper.
191
+ const snapshot = gateElementTransitionShape(value, gate, componentId)
192
+ if (snapshot === undefined) return undefined
193
+ if (hasTransitionMap(snapshot)) {
194
+ const propertyKeys = context.consumers.propertyKeys
195
+ const candidates = new Map<
196
+ string,
197
+ {
198
+ readonly transition: Transition
199
+ consumers: {
200
+ layout: boolean
201
+ property: boolean
202
+ layoutId?: boolean
203
+ }
204
+ }
205
+ >()
206
+ const addCandidate = (
207
+ selectorKey: string,
208
+ consumers: { layout: boolean; property: boolean; layoutId?: boolean },
209
+ ): void => {
210
+ const sourceKey = transitionMapSourceKey(snapshot, selectorKey)
211
+ const existing = candidates.get(sourceKey)
212
+ if (existing !== undefined) {
213
+ existing.consumers.layout ||= consumers.layout
214
+ existing.consumers.property ||= consumers.property
215
+ existing.consumers.layoutId ||= consumers.layoutId === true
216
+ return
217
+ }
218
+ candidates.set(sourceKey, {
219
+ transition: resolveTransitionForKey(snapshot, selectorKey),
220
+ consumers: { ...consumers },
221
+ })
222
+ }
223
+
224
+ // Select the concrete mounted consumers first. Generic validation above still checks every
225
+ // authored branch's shape/ranges; these lane rules apply only to branches this host actually
226
+ // executes, so an unused repeat/keyframe bag cannot poison a valid map.
227
+ if (context.consumers.layout || context.consumers.layoutId === true) {
228
+ addCandidate('layout', {
229
+ layout: true,
230
+ property: false,
231
+ ...(context.consumers.layoutId === true ? { layoutId: true } : {}),
232
+ })
233
+ }
234
+ if (context.consumers.property) {
235
+ const selectedPropertyKeys =
236
+ propertyKeys === undefined || propertyKeys.length === 0 ? ['__root__'] : propertyKeys
237
+ for (const key of selectedPropertyKeys) {
238
+ addCandidate(key, { layout: false, property: true })
239
+ }
240
+ }
241
+ if (candidates.size === 0) {
242
+ addCandidate('__root__', { layout: false, property: false })
243
+ }
244
+
245
+ const invalidSources = new Set<string>()
246
+ for (const [sourceKey, candidate] of candidates) {
247
+ const gated = gateElementTransition(
248
+ candidate.transition,
249
+ { consumers: candidate.consumers },
250
+ gate,
251
+ componentId,
252
+ )
253
+ if (gated === undefined) invalidSources.add(sourceKey)
254
+ }
255
+ return sanitizedTransitionMap(snapshot, invalidSources)
256
+ }
257
+ const layoutId = context.consumers.layoutId === true
258
+ // T23 D (transition-config): a WHOLLY consumer-less element — no layout, no property owner,
259
+ // no layoutId (the map path's synthesized __root__ candidate included) — stores the
260
+ // shape-validated snapshot DORMANT. The per-option consumer-execution laws below need a
261
+ // consumer to be inert AGAINST; with none mounted the config waits, exactly like the pin's
262
+ // lazy getDefaultTransition consumption, and any later-mounted consumer re-gates it.
263
+ if (!context.consumers.layout && !context.consumers.property && !layoutId) {
264
+ return snapshot
265
+ }
266
+ let refusal: Error | null = null
267
+ // `velocity` is EXECUTED by both native lanes since T21 F3: the property lane's spring
268
+ // seeding (authored velocity overrides the live reseed, REQ-SPRING-007) and the layout
269
+ // flight's spring config (toSpringConfig carries it). The pre-T21 unconditional inert
270
+ // refusal is retired (review r1 major 2); layoutId flights still refuse it below with the
271
+ // rest of the spring-physics family.
272
+ // T18-a L4 (REQ-API-049): the repeat family has exactly one executable owner, a PROPERTY
273
+ // command. A layout/layoutId flight animates one snapshot delta, so repeating it would replay
274
+ // the same decay N times — degenerate at best, and it would fold repeat semantics into the
275
+ // layout engine's completion accounting. The pin lets the option flow into a flight; we refuse
276
+ // it loud instead of executing something meaningless (recorded divergence). The split case is
277
+ // refused for the same reason `times` is: one authored transition must execute identically on
278
+ // every consumer it reaches.
279
+ if (refusal === null) {
280
+ const repeated = repeatFamilyKey(snapshot)
281
+ if (repeated !== null) {
282
+ if (!context.consumers.property) {
283
+ refusal = new InvalidTransitionError(
284
+ componentId,
285
+ repeated,
286
+ snapshot[repeated],
287
+ 'only a property animation can repeat — a layout/layoutId flight animates a single ' +
288
+ 'snapshot delta, so repeating it would replay the same decay (T18-a L4 / G-INV-8, ' +
289
+ 'shippedSurface.ts)',
290
+ )
291
+ } else if (context.consumers.layout || layoutId) {
292
+ refusal = new InvalidTransitionError(
293
+ componentId,
294
+ repeated,
295
+ snapshot[repeated],
296
+ 'the repeat family cannot split across shared layout/layoutId and property consumers ' +
297
+ '— one authored transition must execute identically on every consumer, and a ' +
298
+ 'flight never repeats (T18-a L4 / G-INV-8)',
299
+ )
300
+ }
301
+ }
302
+ }
303
+ // U7a: the authored instant lane (`type: false`) is PROPERTY-lane only — the property lane
304
+ // commits the final keyframe on the driver's next update tick (the pin's makeAnimationInstant,
305
+ // motion-dom@12.42.2 motion-value.ts:89-98,122-136). A layout/layoutId flight has no
306
+ // instant-commit wiring, so the option refuses loud here rather than silently flying the
307
+ // default spring (G-INV-8) — a recorded divergence, demand-gated like the layoutId `delay`
308
+ // refusal below. The mixed-consumer arm is the split-execution law: one authored transition
309
+ // must execute identically on every consumer, and a flight cannot execute this one.
310
+ if (refusal === null && snapshot.type === false && (context.consumers.layout || layoutId)) {
311
+ refusal = new InvalidTransitionError(
312
+ componentId,
313
+ 'type',
314
+ false,
315
+ 'a layout/layoutId flight cannot execute type: false — the authored instant lane is ' +
316
+ 'property-only (the property lane commits the final keyframe on the next update tick); ' +
317
+ 'a recorded divergence, demand-gated to a layout-instant successor rung ' +
318
+ '(U7a / G-INV-8, shippedSurface.ts)',
319
+ )
320
+ }
321
+ // `times` and a per-segment `ease` list have exactly one executable owner: a property
322
+ // keyframe-array command. Layout / layoutId flights have no such target.
323
+ if (refusal === null && !context.consumers.property) {
324
+ refusal = keyframeTimingRefusal(componentId, '<layout>', 0, snapshot)
325
+ }
326
+ // Split-execution ban: property can consume keyframe timing while layout/layoutId cannot
327
+ // (2vqwp8 83b5e1d407ac — layoutId+property was missing from the mixed check).
328
+ if (refusal === null && context.consumers.property && (context.consumers.layout || layoutId)) {
329
+ const timingRefusal = keyframeTimingRefusal(componentId, '<property>', 0, snapshot)
330
+ if (timingRefusal !== null) {
331
+ refusal = new InvalidTransitionError(
332
+ componentId,
333
+ timingRefusal.key,
334
+ timingRefusal.value,
335
+ 'keyframe-only timing cannot split across shared layout/layoutId and property ' +
336
+ 'consumers — one authored transition must execute identically on every consumer ' +
337
+ '(REQ-API-033 law d / family-3)',
338
+ )
339
+ }
340
+ }
341
+ // Family-3 + T22 layoutId flight: typed springs (type:'spring', physics or time-defined)
342
+ // execute as a per-flight spring (REQ-LAYOUT-023 a). Two refusals survive: `velocity` — the
343
+ // pin's own projection forces velocity: 0, so executing an authored number would EXCEED the
344
+ // pin, and silently dropping it is the G-INV-8 class; and physics options WITHOUT
345
+ // type:'spring' — no resolvable flight config (the pin runs that bag as an untyped tween
346
+ // that silently ignores the physics; we refuse loud instead). U7c (REQ-SPRING-014):
347
+ // `restDelta`/`restSpeed` join the physics family — executed under an explicit type:'spring'
348
+ // (toSpringConfig carries them into the flight spring), refused in the untyped bag below.
349
+ if (refusal === null && layoutId && snapshot.velocity !== undefined) {
350
+ refusal = new InvalidTransitionError(
351
+ componentId,
352
+ 'velocity',
353
+ snapshot.velocity,
354
+ 'layoutId projection flight refuses velocity — the pinned projection forces velocity 0 ' +
355
+ 'on every flight (REQ-LAYOUT-023 b / G-INV-8)',
356
+ )
357
+ }
358
+ if (
359
+ refusal === null &&
360
+ layoutId &&
361
+ snapshot.type !== 'spring' &&
362
+ (snapshot.stiffness !== undefined ||
363
+ snapshot.damping !== undefined ||
364
+ snapshot.mass !== undefined ||
365
+ snapshot.bounce !== undefined ||
366
+ snapshot.visualDuration !== undefined ||
367
+ snapshot.restDelta !== undefined ||
368
+ snapshot.restSpeed !== undefined)
369
+ ) {
370
+ const springKey =
371
+ snapshot.stiffness !== undefined
372
+ ? 'stiffness'
373
+ : snapshot.damping !== undefined
374
+ ? 'damping'
375
+ : snapshot.mass !== undefined
376
+ ? 'mass'
377
+ : snapshot.bounce !== undefined
378
+ ? 'bounce'
379
+ : snapshot.visualDuration !== undefined
380
+ ? 'visualDuration'
381
+ : snapshot.restDelta !== undefined
382
+ ? 'restDelta'
383
+ : 'restSpeed'
384
+ const springValue = snapshot[springKey]
385
+ refusal = new InvalidTransitionError(
386
+ componentId,
387
+ springKey,
388
+ springValue,
389
+ 'layoutId projection flight executes spring options only under an explicit ' +
390
+ "type: 'spring' — an untyped physics bag has no resolvable flight config " +
391
+ '(REQ-LAYOUT-023 b / G-INV-8)',
392
+ )
393
+ }
394
+ // Ease-only / easings-only without duration or type:tween does not resolve to flight timing
395
+ // (layoutIdFlightTiming) — refuse rather than silent spring fallback (tjynor e30b75a4c19f).
396
+ if (
397
+ refusal === null &&
398
+ layoutId &&
399
+ snapshot.duration === undefined &&
400
+ snapshot.type !== 'tween' &&
401
+ (snapshot.ease !== undefined || snapshot.easings !== undefined || snapshot.easing !== undefined)
402
+ ) {
403
+ const easeKey =
404
+ snapshot.ease !== undefined ? 'ease' : snapshot.easings !== undefined ? 'easings' : 'easing'
405
+ refusal = new InvalidTransitionError(
406
+ componentId,
407
+ easeKey,
408
+ snapshot.ease ?? snapshot.easings ?? snapshot.easing,
409
+ 'layoutId projection flight requires duration or type:tween with ease — ease-only ' +
410
+ 'options are residual (REQ-LAYOUT-023 / G-INV-8)',
411
+ )
412
+ }
413
+ if (refusal === null && layoutId && snapshot.delay !== undefined) {
414
+ // T18-b (REQ-API-050 / packet L5): the property lane executes the delay rebase, so the
415
+ // split-execution refusal is RETIRED — an element mixing layout and property consumers
416
+ // delays both lanes identically, matching the web engine. The consumer-less arm retired
417
+ // at T23 D (the dormant accept above returns before any option law). What remains is the
418
+ // layoutId arm, restated as the divergence it is: the pin passes transition options into
419
+ // projection flights; our FLIP machinery never reads the element transition's delay.
420
+ // Recorded divergence, demand-gated to a successor rung — not a "cannot execute".
421
+ refusal = new InvalidTransitionError(
422
+ componentId,
423
+ 'delay',
424
+ snapshot.delay,
425
+ 'a layoutId projection flight never reads the element transition\u2019s delay — not ' +
426
+ 'executed, a recorded divergence from the pin (whose projection flights consume ' +
427
+ 'transition options); demand-gated to a layoutId-flight-delay successor rung ' +
428
+ '(REQ-API-050, T18-b L5, shippedSurface.ts)',
429
+ )
430
+ }
431
+ if (refusal === null) return snapshot
432
+ if (gate.severity === 'development') throw refusal
433
+ gate.report(refusal)
434
+ return undefined // production: refuse the prop, mount with the default transition
435
+ }
@@ -0,0 +1,52 @@
1
+ // REQ-API-039 — useAnimate on the native entry: [scope, animate] with unmount-stop of tracked
2
+ // animations (pin useUnmountEffect law). Scoped animate resolves selectors inside scope.current
3
+ // and routes every command through the prepared seam (imperativeAnimate.ts).
4
+
5
+ import { useEffect, useMemo } from 'react'
6
+ import { useMotionConfig } from './MotionConfig'
7
+ import {
8
+ createScopedAnimate,
9
+ type AnimateFn,
10
+ type AnimationScope,
11
+ USE_ANIMATE_OWNER,
12
+ } from './imperativeAnimate'
13
+ import { ambientNativeSeverity, consoleReporter } from './severity'
14
+ import { useValueChannel } from './valueHooks'
15
+
16
+ export type { AnimateFn, AnimationPlaybackControls, AnimationScope } from './imperativeAnimate'
17
+
18
+ /** Shallow public pair — mirrors web useAnimate (REQ-API-039; dual-entry type-identity). */
19
+ export type UseAnimateReturn<T = unknown> = readonly [AnimationScope<T>, AnimateFn]
20
+
21
+ export function useAnimate<T = unknown>(): UseAnimateReturn<T> {
22
+ const scope = useMemo<AnimationScope<T>>(
23
+ () => ({
24
+ current: null,
25
+ animations: [],
26
+ }),
27
+ [],
28
+ )
29
+ const { skipAnimations } = useMotionConfig()
30
+ const channel = useValueChannel()
31
+ const severity = ambientNativeSeverity()
32
+ const animate = useMemo(
33
+ () =>
34
+ createScopedAnimate({
35
+ scope: scope as AnimationScope,
36
+ channel,
37
+ skipAnimations,
38
+ gate: { severity, report: consoleReporter, owner: USE_ANIMATE_OWNER },
39
+ }),
40
+ [scope, channel, skipAnimations, severity],
41
+ )
42
+
43
+ // Pin useUnmountEffect law: every animation tracked on scope.animations stops on unmount.
44
+ useEffect(() => {
45
+ return () => {
46
+ for (const animation of scope.animations) animation.stop()
47
+ scope.animations.length = 0
48
+ }
49
+ }, [scope])
50
+
51
+ return [scope, animate]
52
+ }
@@ -0,0 +1,44 @@
1
+ // REQ-API-040 — useCycle on the native entry: pin-faithful cycle through a fixed item list.
2
+ // Pin: framer-motion use-cycle.ts (motion@12.42.2) — [current, cycle]; cycle() advances with
3
+ // wrap; cycle(i) jumps to index. Web re-exports motion/react's useCycle (entry parity).
4
+
5
+ import { useCallback, useRef, useState } from 'react'
6
+
7
+ export type Cycle = (i?: number) => void
8
+ export type CycleState<T> = [T, Cycle]
9
+
10
+ /** Pin wrap (motion-utils): map v into [min, max). */
11
+ function wrap(min: number, max: number, v: number): number {
12
+ const rangeSize = max - min
13
+ if (rangeSize === 0) return min
14
+ return ((((v - min) % rangeSize) + rangeSize) % rangeSize) + min
15
+ }
16
+
17
+ /**
18
+ * Cycles through a series of values (Motion-shaped). Returns `[current, cycle]` where
19
+ * `cycle()` advances to the next item (wrapping) and `cycle(i)` jumps to index `i`.
20
+ *
21
+ * Zero items refuse (REQ-API-040) — the pin would leave undefined state; native fails loud.
22
+ */
23
+ export function useCycle<T>(...items: T[]): CycleState<T> {
24
+ if (items.length === 0) {
25
+ throw new Error(
26
+ 'useCycle: at least one item is required (REQ-API-040) — an empty cycle has no current state.',
27
+ )
28
+ }
29
+
30
+ const index = useRef(0)
31
+ const [item, setItem] = useState(items[index.current] as T)
32
+
33
+ const runCycle = useCallback(
34
+ (next?: number) => {
35
+ index.current = typeof next !== 'number' ? wrap(0, items.length, index.current + 1) : next
36
+ setItem(items[index.current] as T)
37
+ },
38
+ // Pin: items.length first so identity churn still matches on length; then items.
39
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- pin dependency law
40
+ [items.length, ...items],
41
+ )
42
+
43
+ return [item, runCycle]
44
+ }
@@ -0,0 +1,68 @@
1
+ // U7b — useInstantTransition + disableInstantTransitions on the native entry (the
2
+ // instant-transition lane's hook surface). Pin: framer-motion@12.42.2
3
+ // utils/use-instant-transition.ts:9-45 — the hook returns a callback that opens the GLOBAL
4
+ // instant window (native's MotionGlobalConfig.instantAnimations analogue, driver/instantWindow),
5
+ // force-renders the calling component, runs the user's callback, and relocks TWO post-render
6
+ // frames later under the render-count TOKEN guard: a back-to-back call bumps the token, so the
7
+ // stale unlock bails and the window extends (the pin's own test,
8
+ // use-instant-transition.test.tsx:150-176, is the model). There is deliberately NO frame
9
+ // cancellation: an unmounted caller's pending unlock still closes the window, so unmount never
10
+ // leaks it. The window itself (skip generator, final keyframe next update tick, delay ZEROED —
11
+ // F2's asymmetry vs the authored type:false) is consulted at the driver's JS-side transition
12
+ // resolution and pinned in workletDriver.lanes.test.ts. Web re-exports the raw pin.
13
+ //
14
+ // Two native deltas from the pin's mechanics, neither semantic: the force-render is a plain
15
+ // useReducer dispatch (the repo's own idiom — the pin's frame.postRender deferral exists for
16
+ // pre-18 batching; React 18 batches the callback's own setStates into the same commit), and the
17
+ // two post-render frames are nested requestAnimationFrames (native has no motion-dom frame
18
+ // loop). The returned callback is STABLE across the forced renders (the packet's contract): the
19
+ // count the token derives from rides a ref, so identity never changes.
20
+
21
+ import { useCallback, useEffect, useReducer, useRef } from 'react'
22
+ import { closeInstantWindow, openInstantWindow } from '../driver/instantWindow'
23
+
24
+ /**
25
+ * The pin's `useInstantTransition`: a callback that runs `callback` inside the global instant
26
+ * window — every animation starting inside it (any component, any motion value) skips its
27
+ * generator and commits its final keyframe on the driver's next update tick, with `delay`
28
+ * zeroed. The window closes two post-render frames after the last call.
29
+ */
30
+ export function useInstantTransition(): (callback: () => void) => void {
31
+ const [forcedRenderCount, forceUpdate] = useReducer((count: number) => count + 1, 0)
32
+ const forcedRenderCountRef = useRef(forcedRenderCount)
33
+ forcedRenderCountRef.current = forcedRenderCount
34
+ const unlockOnFrameRef = useRef(-1)
35
+
36
+ useEffect(() => {
37
+ // Relock after TWO animation frames — one would unblock before the forced render's
38
+ // animations even start (the pin's double frame.postRender).
39
+ requestAnimationFrame(() =>
40
+ requestAnimationFrame(() => {
41
+ // The token guard: a back-to-back call recorded a NEWER token after this effect
42
+ // scheduled, so this stale unlock bails — the window extends instead of closing
43
+ // early (pin :150-176). No cancellation on unmount: the latest pending unlock
44
+ // still matches its token and closes the window, so unmount never leaks it.
45
+ if (forcedRenderCount !== unlockOnFrameRef.current) return
46
+ closeInstantWindow()
47
+ }),
48
+ )
49
+ }, [forcedRenderCount])
50
+
51
+ return useCallback(
52
+ (callback: () => void) => {
53
+ openInstantWindow()
54
+ forceUpdate()
55
+ callback()
56
+ unlockOnFrameRef.current = forcedRenderCountRef.current + 1
57
+ },
58
+ [forceUpdate],
59
+ )
60
+ }
61
+
62
+ /**
63
+ * The pin's sibling export (use-instant-transition.ts:43-45): clears the global instant window
64
+ * immediately — the escape hatch for a stuck window.
65
+ */
66
+ export function disableInstantTransitions(): void {
67
+ closeInstantWindow()
68
+ }
@@ -0,0 +1,58 @@
1
+ // U7a (the REQ-API-027-adjacent hook row) — useReducedMotion on the native entry: the pin's
2
+ // ACTUAL semantics, not its docblock's. Pin: framer-motion@12.42.2
3
+ // utils/reduced-motion/use-reduced-motion.ts:37-58 — a mount-time SNAPSHOT of the OS
4
+ // reduced-motion preference, frozen for the hook's lifetime: a frozen `useState` initial with NO
5
+ // setter, NO effect, NO subscription (the docblock's "actively respond to changes" is stale; the
6
+ // file's own TODO concedes it). The fidelity doctrine forbids "improving" on that, so native
7
+ // takes NO `reduceMotionChanged` subscription either (packet F1 — the no-re-render test is the
8
+ // unit's fidelity pin). Web re-exports the raw pin (native-motion-web/src/index.ts).
9
+ //
10
+ // The one platform difference is the async transient (packet F2): RN's answer is a promise
11
+ // where the pin's `matchMedia` read is synchronous, so the first-render value is `false` — the
12
+ // pin's own matchMedia-absent fallback (reduced-motion/index.ts:19) — and the resolved OS value
13
+ // replaces it ONCE, inside the mount's snapshot window.
14
+
15
+ import { useEffect, useState } from 'react'
16
+ import { AccessibilityInfo } from 'react-native'
17
+
18
+ declare const __DEV__: boolean | undefined
19
+
20
+ // The pin's warnOnce (motion-utils): one console.warn per key, dev-only.
21
+ const warnedKeys = new Set<string>()
22
+ function warnOnce(condition: boolean, message: string, key: string): void {
23
+ if (condition || warnedKeys.has(key)) return
24
+ warnedKeys.add(key)
25
+ console.warn(message)
26
+ }
27
+
28
+ /**
29
+ * Whether the OS reduced-motion preference was enabled at mount, as a frozen snapshot. First
30
+ * render returns `false`; the resolved OS value lands once, asynchronously. The return type is
31
+ * the pin's wider `boolean | null` shape (the pin's `null` is its SSR/no-window read; native has
32
+ * no SSR host, so the value is never null in practice).
33
+ */
34
+ export function useReducedMotion(): boolean | null {
35
+ const [shouldReduceMotion, setShouldReduceMotion] = useState<boolean | null>(false)
36
+ useEffect(() => {
37
+ // The MotionConfig cancel-flag pattern (MotionConfig.tsx:350-377), minus its subscription:
38
+ // ONE read per mount, one state update on resolve, swallowed after unmount.
39
+ let cancelled = false
40
+ void AccessibilityInfo.isReduceMotionEnabled().then((enabled) => {
41
+ if (!cancelled) setShouldReduceMotion(enabled === true)
42
+ })
43
+ return () => {
44
+ cancelled = true
45
+ }
46
+ }, [])
47
+ if (typeof __DEV__ === 'undefined' || __DEV__ !== false) {
48
+ // The pin's dev-only warnOnce, verbatim message + key (use-reduced-motion.ts:45-51). It
49
+ // fires from the render body like the pin's — on the resolve re-render, never on the
50
+ // first (false) frame.
51
+ warnOnce(
52
+ shouldReduceMotion !== true,
53
+ 'You have Reduced Motion enabled on your device. Animations may not appear as expected.',
54
+ 'reduced-motion-disabled',
55
+ )
56
+ }
57
+ return shouldReduceMotion
58
+ }