@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,61 @@
1
+ // REQ-GESTURE-032 — Android drag hosts register system-gesture exclusion for their bounds.
2
+ //
3
+ // Web has no equivalent strip, so the mapping is a no-op there and on iOS. The module is
4
+ // Android-only autolinking. Host tests mock `react-native` without Platform/NativeModules;
5
+ // vitest throws on a missing named export, so every host access is inside try/catch — a
6
+ // missing module is a no-op, never a throw, because a drag View still has to mount.
7
+ import { findNodeHandle, NativeModules, Platform } from 'react-native'
8
+
9
+ interface ExclusionNative {
10
+ readonly excludeView: (viewTag: number) => void
11
+ readonly clearView: (viewTag: number) => void
12
+ }
13
+
14
+ function exclusionNative(): ExclusionNative | null {
15
+ try {
16
+ if (Platform.OS !== 'android') return null
17
+ const mod = NativeModules.NativeMotionGestureExclusion as ExclusionNative | undefined
18
+ if (
19
+ mod === undefined ||
20
+ typeof mod.excludeView !== 'function' ||
21
+ typeof mod.clearView !== 'function'
22
+ ) {
23
+ return null
24
+ }
25
+ return mod
26
+ } catch {
27
+ return null
28
+ }
29
+ }
30
+
31
+ function nativeTag(node: unknown): number | null {
32
+ if (node === null || node === undefined || typeof node !== 'object') return null
33
+ const tagged = node as { __nativeTag?: unknown; _nativeTag?: unknown; nativeTag?: unknown }
34
+ for (const key of ['__nativeTag', '_nativeTag', 'nativeTag'] as const) {
35
+ const value = tagged[key]
36
+ if (typeof value === 'number') return value
37
+ }
38
+ try {
39
+ if (typeof findNodeHandle !== 'function') return null
40
+ const tag = findNodeHandle(node as Parameters<typeof findNodeHandle>[0])
41
+ return typeof tag === 'number' ? tag : null
42
+ } catch {
43
+ return null
44
+ }
45
+ }
46
+
47
+ /** Register this host's bounds as *this drag is mine*. No-op when the module is absent. */
48
+ export function applyHostGestureExclusion(node: unknown): void {
49
+ const native = exclusionNative()
50
+ const tag = nativeTag(node)
51
+ if (native === null || tag === null) return
52
+ native.excludeView(tag)
53
+ }
54
+
55
+ /** Drop the registration. No-op when the module is absent. */
56
+ export function clearHostGestureExclusion(node: unknown): void {
57
+ const native = exclusionNative()
58
+ const tag = nativeTag(node)
59
+ if (native === null || tag === null) return
60
+ native.clearView(tag)
61
+ }
@@ -0,0 +1,215 @@
1
+ // The tap recognizer protocol (R5, REQ-API-030) as pure, host-agnostic wiring — MotionView
2
+ // binds the substrate's Tap gesture events to this; the pinned semantics live HERE where they
3
+ // are unit-testable (the dragGestureWiring precedent). Pinned contract (motion-dom
4
+ // MotionNodeTapHandlers + framer-motion press.ts at the SS B pin): onTapStart when the press
5
+ // begins on the element; onTap when it ends ON the element (the recognizer succeeded);
6
+ // onTapCancel when a BEGUN press ends unrecognized — an off-element RELEASE (the up-target
7
+ // verdict; no movement threshold exists anywhere, r4 major 27) or a competing gesture (drag)
8
+ // activating. A press that never began dispatches nothing.
9
+
10
+ import type { TapCallback, TapInfo } from '@unrulysystems/native-motion-core'
11
+ import type { HubTapTouchPoint } from './dragHubStream'
12
+ import type { MotionComponentId } from './motionViewController'
13
+ import type { StyleValueGate } from './styleValueBinding'
14
+
15
+ const THROW_GATE: StyleValueGate = {
16
+ severity: 'development',
17
+ report: () => {},
18
+ componentId: '<Motion.View>',
19
+ }
20
+
21
+ /** What the boundary receives before validation — anything the caller authored. */
22
+ export type TapCallbackInput = TapCallback | undefined
23
+
24
+ /**
25
+ * The coordinate slice of the substrate's tap events this wiring reads: absolute (device/page)
26
+ * coordinates feed TapInfo.point; RELATIVE coordinates feed the up-target verdict.
27
+ */
28
+ export interface TapPointEvent {
29
+ readonly absoluteX: number
30
+ readonly absoluteY: number
31
+ readonly x: number
32
+ readonly y: number
33
+ }
34
+
35
+ export interface TapGestureWiring {
36
+ handleBegin(event: TapPointEvent): void
37
+ /** The recognizer ENDED while active; `success` is the substrate's recognition verdict. */
38
+ handleEnd(event: TapPointEvent, success: boolean): void
39
+ /** Always fires last; a begun-but-unrecognized press cancels here, exactly once. */
40
+ handleFinalize(event: TapPointEvent, success: boolean): void
41
+ }
42
+
43
+ export function createTapGestureWiring(options: {
44
+ readonly callbacks: {
45
+ readonly onTap?: TapCallback
46
+ readonly onTapStart?: TapCallback
47
+ readonly onTapCancel?: TapCallback
48
+ }
49
+ /**
50
+ * The element's current layout size, for the pinned UP-TARGET verdict (r2 major 13:
51
+ * motion-dom press decides success from the pointer-up target — `isNodeOrChild(target,
52
+ * upEvent.target)` — so leave-and-return still taps and only the RELEASE point decides).
53
+ * Null before the first layout: begin implies on-element, so the verdict defaults inside.
54
+ */
55
+ readonly getElementSize?: () => { readonly width: number; readonly height: number } | null
56
+ /**
57
+ * U7d (REQ-API-030 global form): the pin's `useGlobalTarget ||` short-circuit
58
+ * (motion-dom press/index.ts:104-112) — in global mode EVERY up resolves success and the
59
+ * release-point containment verdict is bypassed.
60
+ */
61
+ readonly globalTarget?: boolean
62
+ }): TapGestureWiring {
63
+ const { callbacks, getElementSize } = options
64
+ const releasedInside = (event: TapPointEvent): boolean => {
65
+ if (options.globalTarget === true) return true
66
+ const size = getElementSize?.() ?? null
67
+ if (size === null) return true
68
+ return event.x >= 0 && event.x <= size.width && event.y >= 0 && event.y <= size.height
69
+ }
70
+ // Per-cycle state: a cancel fires only for a press that BEGAN and did not succeed.
71
+ let began = false
72
+ let succeeded = false
73
+ const info = (event: TapPointEvent): TapInfo => ({
74
+ point: { x: event.absoluteX, y: event.absoluteY },
75
+ })
76
+ return {
77
+ handleBegin(event) {
78
+ began = true
79
+ succeeded = false
80
+ callbacks.onTapStart?.(event, info(event))
81
+ },
82
+ handleEnd(event, success) {
83
+ if (!began || !success) return
84
+ // The pinned up-target law: the substrate's recognition says the press ENDED normally;
85
+ // whether it is a TAP or a CANCEL is decided by the RELEASE point's containment — a
86
+ // release off the element cancels AT that release point, never a boundary crossing.
87
+ if (releasedInside(event)) {
88
+ succeeded = true
89
+ callbacks.onTap?.(event, info(event))
90
+ return
91
+ }
92
+ succeeded = true // the cycle resolved (as a cancel); finalize must not double-cancel
93
+ callbacks.onTapCancel?.(event, info(event))
94
+ },
95
+ handleFinalize(event, success) {
96
+ if (began && !success && !succeeded) callbacks.onTapCancel?.(event, info(event))
97
+ began = false
98
+ succeeded = false
99
+ },
100
+ }
101
+ }
102
+
103
+ /**
104
+ * U7d (REQ-API-030 global form) — the global press observer. The pin's `press()` with
105
+ * `useGlobalTarget` listens on window: a primary-pointer down ANYWHERE begins the press and
106
+ * every up resolves it (motion-dom press/index.ts). On native the MotionRoot hub touch stream
107
+ * is the window analog (packet F1: observation, never a competing recognizer); this observer
108
+ * carries the pinned press policy at JS altitude over that stream:
109
+ * - the pin's start-gates (press/index.ts:17-19 `isValidPressEvent`): primary pointer only,
110
+ * and never while a drag is active — START-gates only, so an ACTIVE press is not cancelled
111
+ * by a drag starting;
112
+ * - the hub stream's multi-touch law: only the touch that began the press resolves it;
113
+ * - cancel arrives only from the stream's touch-cancel path (the R11 touch-steal mapping).
114
+ */
115
+ export interface GlobalTapObserver {
116
+ onTouchDown(touch: HubTapTouchPoint, primary: boolean): void
117
+ onTouchUp(touch: HubTapTouchPoint): void
118
+ onTouchCancel(touch: HubTapTouchPoint): void
119
+ }
120
+
121
+ export function createGlobalTapObserver(options: {
122
+ readonly wiring: TapGestureWiring
123
+ /** The pin's isDragActive analog (native: the app-global per-axis drag lock). */
124
+ readonly isDragActive: () => boolean
125
+ }): GlobalTapObserver {
126
+ const { wiring, isDragActive } = options
127
+ let pressing: HubTapTouchPoint | null = null
128
+ const eventOf = (touch: HubTapTouchPoint): TapPointEvent => ({
129
+ // The root stream carries screen-absolute coordinates only. The RELATIVE slots mirror the
130
+ // absolute ones — the global verdict never reads relative coordinates (containment is
131
+ // bypassed), and TapInfo.point is built from the absolute pair (the anywhere point).
132
+ x: touch.absoluteX,
133
+ y: touch.absoluteY,
134
+ absoluteX: touch.absoluteX,
135
+ absoluteY: touch.absoluteY,
136
+ })
137
+ return {
138
+ onTouchDown(touch, primary) {
139
+ // The pinned start-gates: primary pointer + no active drag, and one press per surface.
140
+ if (!primary || pressing !== null || isDragActive()) return
141
+ pressing = touch
142
+ wiring.handleBegin(eventOf(touch))
143
+ },
144
+ onTouchUp(touch) {
145
+ if (pressing === null || touch.id !== pressing.id) return
146
+ pressing = null
147
+ // Global mode: EVERY primary-pointer up resolves the press as a successful tap (the
148
+ // containment verdict is bypassed inside the wiring).
149
+ wiring.handleEnd(eventOf(touch), true)
150
+ wiring.handleFinalize(eventOf(touch), true)
151
+ },
152
+ onTouchCancel(touch) {
153
+ if (pressing === null || touch.id !== pressing.id) return
154
+ pressing = null
155
+ // The same cancel path the recognizer's failure rides: finalize without success.
156
+ wiring.handleEnd(eventOf(touch), false)
157
+ wiring.handleFinalize(eventOf(touch), false)
158
+ },
159
+ }
160
+ }
161
+
162
+ /**
163
+ * The tap boundary gate (R5 review r1 major 8): every PRESENT member must be a function —
164
+ * a malformed callback is a typed, named failure under the severity law (development throws;
165
+ * production reports and drops the member while the rest mount), never a raw crash inside
166
+ * the recognizer's dispatch.
167
+ */
168
+ export function gateTapCallbackProps(
169
+ callbacks: {
170
+ readonly onTap?: TapCallbackInput
171
+ readonly onTapStart?: TapCallbackInput
172
+ readonly onTapCancel?: TapCallbackInput
173
+ },
174
+ gate: StyleValueGate = THROW_GATE,
175
+ componentId = '<Motion.View>',
176
+ ): { onTap?: TapCallback; onTapStart?: TapCallback; onTapCancel?: TapCallback } {
177
+ const accepted: { onTap?: TapCallback; onTapStart?: TapCallback; onTapCancel?: TapCallback } = {}
178
+ for (const key of ['onTap', 'onTapStart', 'onTapCancel'] as const) {
179
+ const value = callbacks[key]
180
+ if (value === undefined) continue
181
+ if (typeof value !== 'function') {
182
+ const error = new Error(
183
+ `${componentId}: "${key}" must be a function, received ${typeof value} — the tap callbacks are the ` +
184
+ 'pinned (event, info) handlers (REQ-API-030).',
185
+ )
186
+ if (gate.severity === 'development') throw error
187
+ gate.report(error)
188
+ continue
189
+ }
190
+ accepted[key] = value
191
+ }
192
+ return accepted
193
+ }
194
+
195
+ /**
196
+ * The fixed-at-mount law for the tap surface (R5 review r1 major 8): the recognizer
197
+ * composition is mount-time structure (exactly the drag law) — a FIRST tap callback arriving
198
+ * after mount would be a silently dead gesture, the failure mode this package bans. Removal
199
+ * simply rests (the latest-ref dispatches nothing); re-addition on a mounted surface is live.
200
+ */
201
+ export function gatePostMountTapArrival(
202
+ mountedTapRequested: boolean,
203
+ tapRequestedNow: boolean,
204
+ gate: StyleValueGate = THROW_GATE,
205
+ componentId: MotionComponentId,
206
+ ): void {
207
+ if (mountedTapRequested || !tapRequestedNow) return
208
+ const error = new Error(
209
+ `${componentId}: the tap surface is fixed at mount (REQ-API-030): onTap/onTapStart/onTapCancel arrived ` +
210
+ 'after mount, and the recognizer composition cannot grow — remount the element with ' +
211
+ 'the callbacks present.',
212
+ )
213
+ if (gate.severity === 'development') throw error
214
+ gate.report(error)
215
+ }
@@ -0,0 +1,38 @@
1
+ // REQ-DRIVER-028 — the canonical transform composition order as PURE DATA. composeTransform.ts
2
+ // remains the runtime owner (its worklets build their captured lookup tables from these arrays);
3
+ // the data lives here, RN-import-free, so the transform-string parser (transformStringBinding.ts,
4
+ // U5 W3) and any node-side consumer can derive membership/order without dragging
5
+ // react-native-worklets into their import graph (the mappedKeys.ts precedent: data-only modules
6
+ // for node-side classification). The order is motion-dom's `transformPropOrder`
7
+ // (packages/motion-dom/src/render/utils/keys-transform.ts at the §B pin, motion-dom@12.42.2 /
8
+ // checkout 61833240) restricted to the registry-shipped transform keys. The registry's
9
+ // `perspective` key occupies the pinned `transformPerspective` slot — Motion's IN-TRANSFORM
10
+ // perspective, which RN expresses as a `{perspective}` transform entry. A drift in either
11
+ // authority is caught by composeTransform.test.ts (registry cross-check) and the conformance seam
12
+ // canary (live motion-dom cross-check); this list is deliberately literal so the worklet captures
13
+ // stay plain data (no cross-module calls on the UI runtime).
14
+ export const NATIVE_TRANSFORM_ORDER = Object.freeze([
15
+ 'perspective',
16
+ 'x',
17
+ 'y',
18
+ 'scale',
19
+ 'scaleX',
20
+ 'scaleY',
21
+ 'rotate',
22
+ 'rotateX',
23
+ 'rotateY',
24
+ 'skewX',
25
+ 'skewY',
26
+ ] as const)
27
+
28
+ export type MappedTransformKey = (typeof NATIVE_TRANSFORM_ORDER)[number]
29
+
30
+ // Angle-typed keys commit as RN degree strings; everything else commits numerically. Pinned by
31
+ // test against the registry's `valueType: 'angle'` transform entries.
32
+ export const TRANSFORM_ANGLE_KEYS = Object.freeze([
33
+ 'rotate',
34
+ 'rotateX',
35
+ 'rotateY',
36
+ 'skewX',
37
+ 'skewY',
38
+ ] as const)
@@ -0,0 +1,200 @@
1
+ // U5 W3 (REQ-API-026, packet contract 2) — the pinned-grammar transform-string parser. A bound
2
+ // `transform` MotionValue<string> parses per set() into native transform components, which ride
3
+ // the existing numeric bound-value-retarget lane (styleValueBinding.ts owns the severity
4
+ // wrapping and the write). The grammar is EXACTLY the functions the pin's own buildTransform
5
+ // can emit (motion-dom render/html/utils/build-transform.ts:23-91 at the pin, motion-dom@12.42.2
6
+ // / checkout 61833240 — perspective, translate{X,Y,Z}, scale{,X,Y}, rotate{,X,Y,Z}, skew{,X,Y})
7
+ // PLUS the user-authored template forms translate/translate3d (the pinned catalog's
8
+ // Drag-external-handlers.tsx template emits `translate3d(${x}px, ${y}px, 0)`), restricted to
9
+ // what native's component lane commits (NATIVE_TRANSFORM_ORDER, REQ-DRIVER-028): rotateZ, skew,
10
+ // scaleZ/scale3d, rotate3d, matrix/matrix3d, and any non-zero z component are out of grammar and
11
+ // REFUSE LOUD (the pin hands the string to the browser, which silently drops invalid CSS —
12
+ // native keeps its standing loud policy; declared refuse-class debt, REQ-API-026).
13
+ // Units follow the pin's transformValueTypes (motion-dom value/types/maps/transform.ts:5-36):
14
+ // lengths px, angles deg, scale unitless; a BARE number takes the key's default unit — the pin
15
+ // routes shorthand numbers through getValueAsType with the same maps (build-transform.ts:50-57).
16
+ // A multi-function sequence parses ONLY in native's canonical composition order: a
17
+ // CSS-order-sensitive sequence is inexpressible in the component model, so it refuses loud
18
+ // naming the order sensitivity — never a silent re-order.
19
+ // This module is RN-import-free (the mappedKeys.ts precedent): the order data comes from
20
+ // transformOrder.ts, so node-side consumers and plain unit tests never drag in
21
+ // react-native-worklets.
22
+
23
+ import { NATIVE_TRANSFORM_ORDER, type MappedTransformKey } from './transformOrder'
24
+
25
+ /** The parsed components of a bound transform string — absent keys revert to their pin default. */
26
+ export type TransformStringComponents = Partial<Record<MappedTransformKey, number>>
27
+
28
+ // Canonical composition positions derive from the single data authority (NATIVE_TRANSFORM_ORDER).
29
+ // translateZ's z sits between translateY and scale in the pin's transformPropOrder
30
+ // (keys-transform.ts:4-22), so it orders at y + 0.5 — it writes no component (native has no z
31
+ // channel) but still participates in the order-sensitivity check.
32
+ const ORDER_POSITION: Readonly<Record<string, number>> = Object.freeze(
33
+ Object.fromEntries(NATIVE_TRANSFORM_ORDER.map((key, index) => [key, index])),
34
+ )
35
+ const Z_POSITION = ORDER_POSITION['y']! + 0.5
36
+
37
+ /** Membership probe over the canonical component set (the plain-JS mirror of the worklet's). */
38
+ export function isNativeTransformComponentKey(key: string): key is MappedTransformKey {
39
+ return ORDER_POSITION[key] !== undefined
40
+ }
41
+
42
+ type ArgumentKind = 'length' | 'angle' | 'scale'
43
+
44
+ interface GrammarEntry {
45
+ readonly kind: ArgumentKind
46
+ readonly minArgs: number
47
+ readonly maxArgs: number
48
+ /** The native components the function writes, in canonical order. */
49
+ readonly targets: readonly MappedTransformKey[]
50
+ /** Argument index carrying a z component (must be the identity 0 — native has no z channel). */
51
+ readonly zArgument?: number
52
+ }
53
+
54
+ // The emit grammar the pin's buildTransform can produce PLUS translate/translate3d, restricted
55
+ // to native's committed lane. The angle entries mirror TRANSFORM_ANGLE_KEYS (the registry
56
+ // cross-check in composeTransform.test.ts pins that set); the CSS function names equal the
57
+ // native key names for the whole angle family.
58
+ const GRAMMAR: Readonly<Record<string, GrammarEntry>> = Object.freeze({
59
+ perspective: { kind: 'length', minArgs: 1, maxArgs: 1, targets: ['perspective'] },
60
+ translateX: { kind: 'length', minArgs: 1, maxArgs: 1, targets: ['x'] },
61
+ translateY: { kind: 'length', minArgs: 1, maxArgs: 1, targets: ['y'] },
62
+ // CSS: a missing translate y defaults to 0 — written explicitly so a later translateY/translate3d
63
+ // ordering check sees the occupied slot.
64
+ translate: { kind: 'length', minArgs: 1, maxArgs: 2, targets: ['x', 'y'] },
65
+ translateZ: { kind: 'length', minArgs: 1, maxArgs: 1, targets: [], zArgument: 0 },
66
+ translate3d: { kind: 'length', minArgs: 3, maxArgs: 3, targets: ['x', 'y'], zArgument: 2 },
67
+ scale: { kind: 'scale', minArgs: 1, maxArgs: 1, targets: ['scale'] },
68
+ scaleX: { kind: 'scale', minArgs: 1, maxArgs: 1, targets: ['scaleX'] },
69
+ scaleY: { kind: 'scale', minArgs: 1, maxArgs: 1, targets: ['scaleY'] },
70
+ rotate: { kind: 'angle', minArgs: 1, maxArgs: 1, targets: ['rotate'] },
71
+ rotateX: { kind: 'angle', minArgs: 1, maxArgs: 1, targets: ['rotateX'] },
72
+ rotateY: { kind: 'angle', minArgs: 1, maxArgs: 1, targets: ['rotateY'] },
73
+ skewX: { kind: 'angle', minArgs: 1, maxArgs: 1, targets: ['skewX'] },
74
+ skewY: { kind: 'angle', minArgs: 1, maxArgs: 1, targets: ['skewY'] },
75
+ })
76
+
77
+ const ARGUMENT_PATTERN = /^([+-]?(?:\d+(?:\.\d+)?|\.\d+))([A-Za-z%]*)$/
78
+
79
+ function parseArgument(name: string, token: string, kind: ArgumentKind): number {
80
+ const match = ARGUMENT_PATTERN.exec(token)
81
+ if (match === null) {
82
+ throw new Error(`malformed transform string: '${token}' is not a number ('${name}')`)
83
+ }
84
+ const unit = match[2]!
85
+ const allowedUnits = kind === 'length' ? ['', 'px'] : kind === 'angle' ? ['', 'deg'] : ['']
86
+ if (!allowedUnits.includes(unit)) {
87
+ throw new Error(
88
+ `unit '${unit}' in transform function '${name}' is out of the pinned grammar — ` +
89
+ 'lengths are px, angles deg, scale is unitless (motion-dom transformValueTypes, ' +
90
+ 'value/types/maps/transform.ts:5-36).',
91
+ )
92
+ }
93
+ return Number(match[1])
94
+ }
95
+
96
+ /**
97
+ * Parse a bound transform string into native components. Throws a plain Error naming the
98
+ * unsupported form on ANY deviation from the pinned grammar — the caller (styleValueBinding)
99
+ * wraps it in the severity law naming the style key. 'none' parses to the identity (the pin
100
+ * buildTransform's all-default emission, build-transform.ts:86-88).
101
+ */
102
+ export function parseTransformStringComponents(raw: string): TransformStringComponents {
103
+ const input = raw.trim()
104
+ if (input === '') throw new Error('malformed transform string: empty')
105
+ if (input === 'none') return {}
106
+ const components: Record<string, number> = {}
107
+ let maxPosition = -1
108
+ let previousName: string | null = null
109
+ let rest = input
110
+ while (rest.length > 0) {
111
+ const head = /^([A-Za-z][A-Za-z0-9]*)\s*\(/.exec(rest)
112
+ if (head === null) {
113
+ throw new Error(`malformed transform string near '${rest.slice(0, 24)}'`)
114
+ }
115
+ const name = head[1]!
116
+ const spec = GRAMMAR[name]
117
+ if (spec === undefined) {
118
+ throw new Error(
119
+ `transform function '${name}' is out of the pinned grammar native's component lane ` +
120
+ 'commits (translate{X,Y,Z}/translate/translate3d, scale{,X,Y}, rotate{,X,Y}, ' +
121
+ 'skew{X,Y}, perspective — NATIVE_TRANSFORM_ORDER, REQ-DRIVER-028).',
122
+ )
123
+ }
124
+ rest = rest.slice(head[0].length)
125
+ const close = rest.indexOf(')')
126
+ if (close === -1 || rest.slice(0, close).includes('(')) {
127
+ throw new Error(`malformed transform string: unclosed '${name}('`)
128
+ }
129
+ const inner = rest.slice(0, close)
130
+ rest = rest.slice(close + 1).trimStart()
131
+ const tokens = inner.trim() === '' ? [] : inner.split(',').map((token) => token.trim())
132
+ if (tokens.length < spec.minArgs || tokens.length > spec.maxArgs) {
133
+ const expected =
134
+ spec.minArgs === spec.maxArgs ? `${spec.minArgs}` : `${spec.minArgs}-${spec.maxArgs}`
135
+ throw new Error(
136
+ `transform function '${name}' takes ${expected} argument(s) in the pinned grammar, ` +
137
+ `got ${tokens.length}.`,
138
+ )
139
+ }
140
+ const values = tokens.map((token) => parseArgument(name, token, spec.kind))
141
+ if (spec.zArgument !== undefined && values[spec.zArgument] !== 0) {
142
+ throw new Error(
143
+ `transform function '${name}' carries a non-zero z (${String(values[spec.zArgument])}) — ` +
144
+ "native's component lane has no z channel (NATIVE_TRANSFORM_ORDER, REQ-DRIVER-028).",
145
+ )
146
+ }
147
+ // One authority per component, in canonical order only (REQ-DRIVER-028): a duplicate write
148
+ // or a backward step is a CSS-order-sensitive sequence the component model cannot express.
149
+ const positions =
150
+ spec.targets.length === 0 ? [Z_POSITION] : spec.targets.map((key) => ORDER_POSITION[key]!)
151
+ for (let index = 0; index < spec.targets.length; index++) {
152
+ const key = spec.targets[index]!
153
+ if (key in components) {
154
+ throw new Error(
155
+ `transform string writes component '${key}' twice ('${name}') — one authority per component.`,
156
+ )
157
+ }
158
+ if (positions[index]! <= maxPosition) {
159
+ throw new Error(
160
+ `transform string is order-sensitive: '${name}' must not follow '${previousName ?? ''}' — ` +
161
+ 'native composes in canonical order (REQ-DRIVER-028) and cannot express ' +
162
+ 'CSS-order-sensitive sequences.',
163
+ )
164
+ }
165
+ maxPosition = positions[index]!
166
+ // CSS translate's missing y is 0 — values[index] is always present for every other target.
167
+ components[key] = values[index] ?? 0
168
+ }
169
+ if (spec.targets.length === 0) {
170
+ if (Z_POSITION <= maxPosition) {
171
+ throw new Error(
172
+ `transform string is order-sensitive: '${name}' must not follow '${previousName ?? ''}' — ` +
173
+ 'native composes in canonical order (REQ-DRIVER-028) and cannot express ' +
174
+ 'CSS-order-sensitive sequences.',
175
+ )
176
+ }
177
+ maxPosition = Z_POSITION
178
+ }
179
+ previousName = name
180
+ }
181
+ return components
182
+ }
183
+
184
+ /**
185
+ * The per-set() write map: every canonical component, parsed value or pin default. The pin's
186
+ * imperative `element.style.transform = str` REPLACES the whole transform (build-styles.ts:52-66:
187
+ * a present `latestValues.transform` skips the component build entirely), so a component absent
188
+ * from the string reverts to identity — carried explicitly here (0; 1 for the scale family —
189
+ * build-transform.ts:44,47 `key.startsWith("scale") ? 1 : 0`). One multi-key
190
+ * bound-value-retarget write per set(), stateless.
191
+ */
192
+ export function transformStringWriteMap(
193
+ components: TransformStringComponents,
194
+ ): Record<string, number> {
195
+ const map: Record<string, number> = {}
196
+ for (const key of NATIVE_TRANSFORM_ORDER) {
197
+ map[key] = components[key] ?? (key.startsWith('scale') ? 1 : 0)
198
+ }
199
+ return map
200
+ }
@@ -0,0 +1,45 @@
1
+ // T17 (specs/T17-TRANSFORM-TEMPLATE-BUILD-PACKET.md floor 2): the transformTemplate mount gate.
2
+ // The template executes on the UI runtime every frame, so the prop must be a FUNCTION and that
3
+ // function must be a BRANDED worklet — `__workletHash` is the worklets serializer's only
4
+ // discriminator (AGENTS.md), burned in by the 'worklet' babel directive in the app's pipeline.
5
+ // An unbranded function would either silently per-frame cross back to JS (the pin's call happens
6
+ // on the UI thread natively) or crash as a remote call mid-frame; both are refused at mount under
7
+ // the severity law (development throws; production reports and treats the prop as absent — the
8
+ // canonical composition paints). The brand check is NATIVE-ONLY: on web the same source compiles
9
+ // without the worklets plugin and motion/react calls the plain function directly.
10
+
11
+ import type { TransformTemplate } from '@unrulysystems/native-motion-core'
12
+ import type { MotionComponentId, NativeSeverity } from './motionViewController'
13
+
14
+ // The serializer's discriminator shape — a branded worklet carries its hash as an own property.
15
+ type BrandedWorklet = TransformTemplate & { readonly __workletHash?: unknown }
16
+
17
+ export function gateTransformTemplateProp(
18
+ value: unknown,
19
+ severity: NativeSeverity,
20
+ report: (error: Error) => void,
21
+ componentId: MotionComponentId,
22
+ ): TransformTemplate | undefined {
23
+ if (value === undefined) return undefined
24
+ const refusal = (): Error | null => {
25
+ if (typeof value !== 'function') {
26
+ return new Error(
27
+ `${componentId}: "transformTemplate" must be a function ` +
28
+ `(transform, generatedTransform) => string, received ${typeof value} (T17; pin MotionNodeOptions).`,
29
+ )
30
+ }
31
+ if ((value as BrandedWorklet).__workletHash === undefined) {
32
+ return new Error(
33
+ `${componentId}: "transformTemplate" must carry the 'worklet' directive — the template ` +
34
+ 'executes on the UI runtime every frame (T17), so an un-workletized function cannot be ' +
35
+ "marshaled. Add 'worklet' as the function body's first statement.",
36
+ )
37
+ }
38
+ return null
39
+ }
40
+ const error = refusal()
41
+ if (error === null) return value as TransformTemplate
42
+ if (severity === 'development') throw error
43
+ report(error)
44
+ return undefined
45
+ }