@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.
- package/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/README.md +55 -0
- package/android/build.gradle +24 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/com/unrulysystems/nativemotion/GestureExclusionModule.kt +55 -0
- package/android/src/main/java/com/unrulysystems/nativemotion/NativeMotionPackage.kt +27 -0
- package/package.json +57 -0
- package/react-native.config.js +14 -0
- package/src/native/driver/instantWindow.ts +31 -0
- package/src/native/driver/strictModeReplay.ts +20 -0
- package/src/native/driver/uiLayoutEngine.ts +845 -0
- package/src/native/driver/uiLayoutGraph.ts +338 -0
- package/src/native/driver/uiValueChannel.ts +4640 -0
- package/src/native/driver/workletDriver.ts +3860 -0
- package/src/native/motion/AnimatePresence.tsx +1605 -0
- package/src/native/motion/LayoutGroup.tsx +163 -0
- package/src/native/motion/MotionConfig.tsx +400 -0
- package/src/native/motion/MotionRoot.tsx +250 -0
- package/src/native/motion/MotionView.tsx +6761 -0
- package/src/native/motion/addScaleCorrector.ts +97 -0
- package/src/native/motion/colorEndpointUiFeed.ts +32 -0
- package/src/native/motion/colorProjection.ts +39 -0
- package/src/native/motion/composeTransform.ts +271 -0
- package/src/native/motion/constraintMeasure.ts +157 -0
- package/src/native/motion/deferredPendingSnapshots.ts +25 -0
- package/src/native/motion/discreteProjection.ts +36 -0
- package/src/native/motion/dragAncestorPanContext.ts +28 -0
- package/src/native/motion/dragControls.ts +152 -0
- package/src/native/motion/dragGestureWiring.ts +1168 -0
- package/src/native/motion/dragHandoffBinding.ts +179 -0
- package/src/native/motion/dragHubStream.ts +756 -0
- package/src/native/motion/dragPropagationLock.ts +57 -0
- package/src/native/motion/driverValueChannel.ts +3288 -0
- package/src/native/motion/externalDragDriver.ts +214 -0
- package/src/native/motion/frameData.ts +31 -0
- package/src/native/motion/gestureBinding.ts +107 -0
- package/src/native/motion/gestureStateGate.ts +563 -0
- package/src/native/motion/gestureStateResolver.ts +286 -0
- package/src/native/motion/identityValueChannelControllerAdapter.ts +2043 -0
- package/src/native/motion/identityValueChannelControllerReconciliation.ts +176 -0
- package/src/native/motion/identityValueChannelLaneMarker.ts +14 -0
- package/src/native/motion/imperativeAnimate.ts +816 -0
- package/src/native/motion/keyframeTiming.ts +7 -0
- package/src/native/motion/layoutIdBinding.ts +261 -0
- package/src/native/motion/layoutIdFlightConfig.ts +51 -0
- package/src/native/motion/layoutProjection.ts +89 -0
- package/src/native/motion/layoutScroll.ts +204 -0
- package/src/native/motion/layoutTransition.ts +1302 -0
- package/src/native/motion/lengthLayoutContext.tsx +100 -0
- package/src/native/motion/lengthLayoutHost.ts +44 -0
- package/src/native/motion/mappedKeys.ts +184 -0
- package/src/native/motion/motionViewController.ts +3180 -0
- package/src/native/motion/nativeHostMarker.ts +22 -0
- package/src/native/motion/panSessionWiring.ts +127 -0
- package/src/native/motion/pathTransition.ts +101 -0
- package/src/native/motion/popLayout.ts +176 -0
- package/src/native/motion/presenceBinding.ts +382 -0
- package/src/native/motion/scaleCorrectorRegistry.ts +123 -0
- package/src/native/motion/scrollValues.ts +260 -0
- package/src/native/motion/serializablePayload.ts +151 -0
- package/src/native/motion/severity.ts +16 -0
- package/src/native/motion/shippedSurface.ts +843 -0
- package/src/native/motion/staticLengthGate.ts +86 -0
- package/src/native/motion/styleBaseGate.ts +72 -0
- package/src/native/motion/styleValueBinding.ts +657 -0
- package/src/native/motion/systemGestureExclusion.ts +61 -0
- package/src/native/motion/tapGestureWiring.ts +215 -0
- package/src/native/motion/transformOrder.ts +38 -0
- package/src/native/motion/transformStringBinding.ts +200 -0
- package/src/native/motion/transformTemplateGate.ts +45 -0
- package/src/native/motion/transitionGate.ts +435 -0
- package/src/native/motion/useAnimate.ts +52 -0
- package/src/native/motion/useCycle.ts +44 -0
- package/src/native/motion/useInstantTransition.ts +68 -0
- package/src/native/motion/useReducedMotion.ts +58 -0
- package/src/native/motion/useScroll.ts +162 -0
- package/src/native/motion/useViewportScroll.ts +29 -0
- package/src/native/motion/valueChannel.ts +7121 -0
- package/src/native/motion/valueHooks.ts +901 -0
- package/src/native/motion/variantChildRegistry.ts +67 -0
- package/src/native/motion/variantContext.tsx +172 -0
- package/src/native/motion/variantProps.ts +598 -0
- package/src/native.ts +159 -0
- package/src/verification/harnessMetrics.ts +78 -0
- package/src/verification/probe/LayoutIdentityWorkletProbe.tsx +251 -0
- package/src/verification/probe/WorkletParityProbe.tsx +162 -0
- package/src/verification/probe/layoutIdentityProbeEngine.ts +429 -0
- package/src/verification/screens/ArcPathChecksScreen.tsx +273 -0
- package/src/verification/screens/BooleanAnimateChecksScreen.tsx +358 -0
- package/src/verification/screens/ChoreographyGalleryScreen.tsx +1010 -0
- package/src/verification/screens/ColorBindingChecksScreen.tsx +568 -0
- package/src/verification/screens/CompletionChecksScreen.tsx +294 -0
- package/src/verification/screens/ConformanceScreen.tsx +573 -0
- package/src/verification/screens/ContentionProbeScreen.tsx +224 -0
- package/src/verification/screens/DriverSmokeScreen.tsx +99 -0
- package/src/verification/screens/DurationOnlyTweenChecksScreen.tsx +294 -0
- package/src/verification/screens/DynamicDragConfigChecksScreen.tsx +237 -0
- package/src/verification/screens/FrameDataChecksScreen.tsx +252 -0
- package/src/verification/screens/GestureChecksScreen.tsx +685 -0
- package/src/verification/screens/InstantTransitionChecksScreen.tsx +839 -0
- package/src/verification/screens/LayoutAnimationStartChecksScreen.tsx +648 -0
- package/src/verification/screens/LayoutChecksScreen.tsx +822 -0
- package/src/verification/screens/LayoutCommitSpikeScreen.tsx +142 -0
- package/src/verification/screens/MotionViewChecksScreen.tsx +930 -0
- package/src/verification/screens/PresenceChecksScreen.tsx +614 -0
- package/src/verification/screens/ReducedMotionChecksScreen.tsx +614 -0
- package/src/verification/screens/RestThresholdChecksScreen.tsx +634 -0
- package/src/verification/screens/ScaleCorrectorChecksScreen.tsx +425 -0
- package/src/verification/screens/SharedLayoutContinuityChecksScreen.tsx +338 -0
- package/src/verification/screens/SharedLayoutCrossfadeChecksScreen.tsx +425 -0
- package/src/verification/screens/TransitionDefaultSelectionChecksScreen.tsx +514 -0
- package/src/verification/screens/ViewportScrollAliasChecksScreen.tsx +303 -0
- package/src/verification/screens/conformanceBanner.ts +21 -0
- package/src/verification/screens/proofConsoleTap.ts +18 -0
- package/src/verification.ts +47 -0
- 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
|
+
}
|