@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,97 @@
1
+ // U7g (REQ-LAYOUT-024): native addScaleCorrector. Pin motion-dom scale-correction.ts merges
2
+ // into a JS map; native projection runs on the UI thread so every `correct` must be a branded
3
+ // worklet (F1). CSS-variable keys and unmapped keys refuse loud (no native meaning). boxShadow
4
+ // has no native mapping (F3) — never a silent built-in.
5
+
6
+ import { scheduleOnUI } from 'react-native-worklets'
7
+ import { NUMERIC_STYLE_KEY_NAMES } from './mappedKeys'
8
+ import { installScaleCorrectorOnUI } from './scaleCorrectorRegistry'
9
+ import { ambientNativeSeverity, consoleReporter } from './severity'
10
+
11
+ export interface ScaleCorrectionContext {
12
+ readonly targetDelta: {
13
+ readonly x: { readonly scale: number }
14
+ readonly y: { readonly scale: number }
15
+ }
16
+ readonly treeScale: { readonly x: number; readonly y: number }
17
+ }
18
+
19
+ export type ScaleCorrector = (
20
+ latest: number | string,
21
+ context: ScaleCorrectionContext,
22
+ ) => number | string
23
+
24
+ export interface ScaleCorrectorDefinition {
25
+ readonly correct: ScaleCorrector
26
+ readonly applyTo?: readonly string[]
27
+ }
28
+
29
+ export interface ScaleCorrectorMap {
30
+ readonly [key: string]: ScaleCorrectorDefinition
31
+ }
32
+
33
+ type BrandedWorklet = ScaleCorrector & { readonly __workletHash?: unknown }
34
+
35
+ const BINDABLE = new Set<string>(NUMERIC_STYLE_KEY_NAMES)
36
+
37
+ function isCssVariableName(key: string): boolean {
38
+ return key.startsWith('--')
39
+ }
40
+
41
+ function refusalForEntry(key: string, definition: ScaleCorrectorMap[string]): Error | null {
42
+ if (isCssVariableName(key)) {
43
+ return new Error(
44
+ `addScaleCorrector: "${key}" is a CSS variable — native has no CSS-variable style ` +
45
+ 'surface (REQ-LAYOUT-024). Register a bindable native style key.',
46
+ )
47
+ }
48
+ if (!BINDABLE.has(key)) {
49
+ return new Error(
50
+ `addScaleCorrector: "${key}" is not a bindable native style key (REQ-LAYOUT-024). ` +
51
+ 'Native scale correction applies to mapped numeric layout-style keys only.',
52
+ )
53
+ }
54
+ if (typeof definition.correct !== 'function') {
55
+ return new Error(
56
+ `addScaleCorrector: "${key}".correct must be a function (latest, context) => value.`,
57
+ )
58
+ }
59
+ if ((definition.correct as BrandedWorklet).__workletHash === undefined) {
60
+ return new Error(
61
+ `addScaleCorrector: "${key}".correct must carry the 'worklet' directive — native ` +
62
+ 'projection corrects on the UI runtime every frame (REQ-LAYOUT-024 F1). Add ' +
63
+ "'worklet' as the function body's first statement.",
64
+ )
65
+ }
66
+ const applyTo = definition.applyTo
67
+ if (applyTo !== undefined) {
68
+ for (const target of applyTo) {
69
+ if (isCssVariableName(target) || !BINDABLE.has(target)) {
70
+ return new Error(
71
+ `addScaleCorrector: "${key}".applyTo contains "${target}", which is not a ` +
72
+ 'bindable native style key (REQ-LAYOUT-024).',
73
+ )
74
+ }
75
+ }
76
+ }
77
+ return null
78
+ }
79
+
80
+ /**
81
+ * Register per-frame projection scale correctors. Each `correct` runs on the UI runtime
82
+ * during an active projection transform (the pin's addScaleCorrector channel).
83
+ */
84
+ export function addScaleCorrector(correctors: ScaleCorrectorMap): void {
85
+ const severity = ambientNativeSeverity()
86
+ for (const key of Object.keys(correctors)) {
87
+ const definition = correctors[key]!
88
+ const error = refusalForEntry(key, definition)
89
+ if (error !== null) {
90
+ if (severity === 'development') throw error
91
+ consoleReporter(error)
92
+ continue
93
+ }
94
+ const applyTo = definition.applyTo === undefined ? null : [...definition.applyTo]
95
+ scheduleOnUI(installScaleCorrectorOnUI, key, definition.correct, applyTo)
96
+ }
97
+ }
@@ -0,0 +1,32 @@
1
+ // Endpoint SharedValue feeds must not mutate from the JS guest (iOS serialization hang,
2
+ // PROOF §SIGABRT-1 Q3 / host-feed law): a JS-thread `cell.value = …` on a Reanimated SharedValue
3
+ // schedules flushUIQueue → workletFunction → mutable, the hang tail
4
+ // /__closure/workletFunction/__closure/mutable/__closure. Mount-time makeMutable is fine (no
5
+ // guest write); retargets ride scheduleOnUI + this named worklet (primitive/branded args only).
6
+
7
+ import type { RGBA } from '@unrulysystems/native-motion-core'
8
+
9
+ /**
10
+ * UI-lane writer for a color keyframe endpoint sequence SharedValue.
11
+ * Call only via scheduleOnUI(setColorEndpointSequenceOnUI, cell, sequence).
12
+ */
13
+ export function setColorEndpointSequenceOnUI(
14
+ sequenceCell: { value: readonly RGBA[] },
15
+ sequence: readonly RGBA[],
16
+ ): void {
17
+ 'worklet'
18
+ sequenceCell.value = sequence
19
+ }
20
+
21
+ /**
22
+ * UI-lane writer for a discrete [from, to] keyword pair SharedValue (T23 B2b — same guest-write
23
+ * law as the color feed; strings are primitive-safe for the crossing).
24
+ * Call only via scheduleOnUI(setDiscreteEndpointPairOnUI, cell, pair).
25
+ */
26
+ export function setDiscreteEndpointPairOnUI(
27
+ pairCell: { value: readonly string[] },
28
+ pair: readonly string[],
29
+ ): void {
30
+ 'worklet'
31
+ pairCell.value = pair
32
+ }
@@ -0,0 +1,39 @@
1
+ // L1's color projection seam (specs/L1-BUILD-PACKET.md §semantics 3): the ONE function that turns a
2
+ // driver progress scalar into the RN backgroundColor string, shared by the animated-style worklet and
3
+ // the deterministic checks so the two paths can never fork (the M2.4 routePanEnd exported-seam
4
+ // pattern). Pure core plumbing — SPRING owns the [0,100] progress convention (REQ-SPRING-013), core
5
+ // value-types own the linear-light mix + byte-exact rgba() projection (REQ-VALUETYPE-006). Runs on
6
+ // the UI runtime (worklet-tagged core dist, the M2.1-proven crossing).
7
+
8
+ import { mixColor, transformColor, type RGBA } from '@unrulysystems/native-motion-core'
9
+
10
+ // progress rides the driver's [0, 100] internal convention; the mix seam consumes a [0, 1] fraction.
11
+ export function projectColor(from: RGBA, to: RGBA, progress: number): string {
12
+ 'worklet'
13
+ return transformColor(mixColor(from, to, progress / 100))
14
+ }
15
+
16
+ // The SEQUENCE projection for a color keyframe array (R8 M2, review major 35): the driver drives a
17
+ // numeric PROGRESS keyframe array [0 … 100] so the shipped keyframe generator owns offsets/per-segment
18
+ // easing; this maps the live progress to a position through the RGBA sequence, then mixColors the
19
+ // adjacent pair for that segment. A 2-color sequence reduces EXACTLY to `projectColor`
20
+ // (position = progress/100, segment 0). The sequence is length ≥2 (validated upstream).
21
+ export function projectColorSequence(sequence: readonly RGBA[], progress: number): string {
22
+ 'worklet'
23
+ const lastSegment = sequence.length - 2 // ≥0 for a length-≥2 sequence
24
+ const position = (progress / 100) * (sequence.length - 1)
25
+ const floored = Math.floor(position)
26
+ const segment = floored < 0 ? 0 : floored > lastSegment ? lastSegment : floored
27
+ return transformColor(mixColor(sequence[segment]!, sequence[segment + 1]!, position - segment))
28
+ }
29
+
30
+ // The RGBA the sequence renders at `progress` (the un-projected sibling of `projectColorSequence`):
31
+ // the JS-side re-seed of a color UPDATE reads the live color THROUGH the sequence (major 35), replacing
32
+ // the old two-endpoint `mixColor(from, to, …)`. Same segment math; returns the mixed RGBA, not a string.
33
+ export function sampleColorSequence(sequence: readonly RGBA[], progress: number): RGBA {
34
+ const lastSegment = sequence.length - 2 // ≥0 for a length-≥2 sequence
35
+ const position = (progress / 100) * (sequence.length - 1)
36
+ const floored = Math.floor(position)
37
+ const segment = floored < 0 ? 0 : floored > lastSegment ? lastSegment : floored
38
+ return mixColor(sequence[segment]!, sequence[segment + 1]!, position - segment)
39
+ }
@@ -0,0 +1,271 @@
1
+ import { scheduleOnRN } from 'react-native-worklets'
2
+ import {
3
+ buildTransformTemplateArgs,
4
+ parseTransformTemplateOutput,
5
+ type TransformTemplate,
6
+ } from '@unrulysystems/native-motion-core'
7
+ import { deferUiOriginatedCallback } from '../driver/workletDriver'
8
+ import { isMotionValue } from './styleValueBinding'
9
+ import {
10
+ NATIVE_TRANSFORM_ORDER,
11
+ TRANSFORM_ANGLE_KEYS,
12
+ type MappedTransformKey,
13
+ } from './transformOrder'
14
+
15
+ // REQ-DRIVER-028 — canonical transform composition order. The order/angle DATA lives in
16
+ // transformOrder.ts (RN-import-free, so the U5-W3 transform-string parser shares the single
17
+ // authority without dragging react-native-worklets into its graph); this module stays the
18
+ // runtime owner — the composite RN `transform` array is built in pinned Motion's order so
19
+ // authoring order never changes the visual result. Re-exported here so every existing consumer
20
+ // (MotionView, layoutTransition, the registry cross-check test) keeps one import site.
21
+ export { NATIVE_TRANSFORM_ORDER, TRANSFORM_ANGLE_KEYS, type MappedTransformKey }
22
+
23
+ // The RN transform entry name differs from the Motion target key only for the translations.
24
+ const RN_ENTRY_NAME: Record<MappedTransformKey, string> = Object.freeze({
25
+ perspective: 'perspective',
26
+ x: 'translateX',
27
+ y: 'translateY',
28
+ scale: 'scale',
29
+ scaleX: 'scaleX',
30
+ scaleY: 'scaleY',
31
+ rotate: 'rotate',
32
+ rotateX: 'rotateX',
33
+ rotateY: 'rotateY',
34
+ skewX: 'skewX',
35
+ skewY: 'skewY',
36
+ })
37
+
38
+ // Plain-object lookups (not Sets): worklet captures stay in the most conservative shareable
39
+ // shape — records of primitives — so serialization can never be the failure mode on the UI runtime.
40
+ const ANGLE_KEY_INDEX: Record<string, 1> = Object.freeze(
41
+ Object.fromEntries(TRANSFORM_ANGLE_KEYS.map((key) => [key, 1 as const])),
42
+ )
43
+ const ORDER_INDEX: Record<string, number> = Object.freeze(
44
+ Object.fromEntries(NATIVE_TRANSFORM_ORDER.map((key, index) => [key, index])),
45
+ )
46
+
47
+ // Membership probe for MotionView's animated-style worklet: routes a mounted key to the transform
48
+ // composite vs the plain style path without hand-listing keys at the call site.
49
+ export function isMappedTransformKey(key: string): key is MappedTransformKey {
50
+ 'worklet'
51
+ return ORDER_INDEX[key] !== undefined
52
+ }
53
+
54
+ // Layout's Reanimated-owned frames must name transform entries exactly like the ordinary
55
+ // animated-style lane. Keeping the translation aliases here prevents a layout/property flight
56
+ // from producing `{ x }` or `{ y }`, which React Native does not recognize as transform entries.
57
+ export function nativeTransformEntryName(key: MappedTransformKey): string {
58
+ 'worklet'
59
+ return RN_ENTRY_NAME[key]
60
+ }
61
+
62
+ /**
63
+ * Layout's host-proxy mirror is another producer of the same RN transform array as the ordinary
64
+ * animated-style lane. Keeping its ordering lookup here prevents an authoring-order divergence
65
+ * between the two producers (REQ-DRIVER-028).
66
+ */
67
+ export function compareMappedTransformKeys(a: MappedTransformKey, b: MappedTransformKey): number {
68
+ 'worklet'
69
+ return ORDER_INDEX[a]! - ORDER_INDEX[b]!
70
+ }
71
+
72
+ // Pure and workletized: MotionView's animated style calls this on the UI runtime every frame; unit
73
+ // tests call it as a plain function. Captures are frozen plain data only (order table, name map,
74
+ // angle set) — no cross-module calls cross the worklet boundary.
75
+
76
+ /** Family-5 gate result: finite numeric seeds vs mapped keys that must be severity-refused + stripped. */
77
+ export type StaticTransformStyleGate = {
78
+ readonly seeds: Readonly<Record<string, number>>
79
+ readonly refusedKeys: readonly MappedTransformKey[]
80
+ }
81
+
82
+ /**
83
+ * Family-5: classify every authored mapped transform shorthand on a flattened style bag.
84
+ * Finite numbers seed composeTransform; exact public MotionValues leave for the binding lane
85
+ * (full surface, same predicate as styleValueBinding); any other present value is refused
86
+ * (NaN / Infinity / string / near-MotionValue / opaque) so the host never keeps a silent
87
+ * RN-dropped residual.
88
+ */
89
+ export function gateStaticTransformStyle(
90
+ style: Readonly<Record<string, unknown>>,
91
+ ): StaticTransformStyleGate {
92
+ const seeds: Record<string, number> = {}
93
+ const refusedKeys: MappedTransformKey[] = []
94
+ for (let i = 0; i < NATIVE_TRANSFORM_ORDER.length; i++) {
95
+ const key = NATIVE_TRANSFORM_ORDER[i]!
96
+ if (!Object.prototype.hasOwnProperty.call(style, key)) continue
97
+ const value = style[key]
98
+ // Full MotionValue only — partial duck-types are refused (not left for a crashing attach).
99
+ if (isMotionValue(value)) continue
100
+ if (typeof value === 'number' && Number.isFinite(value)) {
101
+ seeds[key] = value
102
+ } else {
103
+ refusedKeys.push(key)
104
+ }
105
+ }
106
+ return { seeds, refusedKeys }
107
+ }
108
+
109
+ /**
110
+ * Family-5: extract finite numeric Motion transform shorthands from a flattened style record.
111
+ * Pure JS (not a worklet) — called at render to seed composeTransform for static style transforms.
112
+ */
113
+ export function extractStaticTransformSeeds(
114
+ style: Readonly<Record<string, unknown>>,
115
+ ): Readonly<Record<string, number>> {
116
+ return gateStaticTransformStyle(style).seeds
117
+ }
118
+
119
+ export function composeTransform(
120
+ values: Readonly<Record<string, number>>,
121
+ ): Record<string, number | string>[] {
122
+ 'worklet'
123
+ const authored = Object.keys(values)
124
+ const present: string[] = []
125
+ let pathRotation = 0
126
+ for (let i = 0; i < authored.length; i++) {
127
+ const key = authored[i]!
128
+ if (key === 'pathRotation') {
129
+ pathRotation = values.pathRotation ?? 0
130
+ continue
131
+ }
132
+ if (ORDER_INDEX[key] === undefined) {
133
+ // The unmapped-key path is a driver-registry skew surfaced at the animated-style worklet —
134
+ // an operating condition on a LIVE UI callback, never an invariant worth a UI throw (seal
135
+ // r7, F1, iOS AUTO SIGABRT campaign): the key is REFUSED from the output and reported once
136
+ // per key per app run through the scheduleOnRN severity channel. The UI-global dedupe is
137
+ // required because module state is cloned per worklet materialization and never persists.
138
+ const refusalGlobal = globalThis as UnmappedTransformKeyRefusalUiGlobal
139
+ if (refusalGlobal.__nativeMotionUnmappedTransformKeyRefusals === undefined)
140
+ refusalGlobal.__nativeMotionUnmappedTransformKeyRefusals = {}
141
+ if (refusalGlobal.__nativeMotionUnmappedTransformKeyRefusals[key] === undefined) {
142
+ refusalGlobal.__nativeMotionUnmappedTransformKeyRefusals[key] = true
143
+ scheduleOnRN(reportUnmappedTransformKeyRefusal, key)
144
+ }
145
+ continue
146
+ }
147
+ present.push(key)
148
+ }
149
+ present.sort((a, b) => ORDER_INDEX[a]! - ORDER_INDEX[b]!)
150
+ const out: Record<string, number | string>[] = []
151
+ for (let i = 0; i < present.length; i++) {
152
+ const key = present[i] as MappedTransformKey
153
+ const value = values[key]!
154
+ out.push({
155
+ [nativeTransformEntryName(key)]: ANGLE_KEY_INDEX[key] === 1 ? `${value}deg` : value,
156
+ })
157
+ }
158
+ // U8 (REQ-API-059): pin composes pathRotation as a separate additive rotate after the user's
159
+ // rotate so concurrent rotate is never clobbered. Skip when zero (pin `if (pathRotation)`).
160
+ if (pathRotation) out.push({ rotate: `${pathRotation}deg` })
161
+ return out
162
+ }
163
+
164
+ interface UnmappedTransformKeyRefusalUiGlobal {
165
+ __nativeMotionUnmappedTransformKeyRefusals?: Record<string, true>
166
+ }
167
+
168
+ /**
169
+ * T17 (specs/T17-TRANSFORM-TEMPLATE-BUILD-PACKET.md floor 3): the per-frame template path. The
170
+ * branded-worklet template is called ON THE UI RUNTIME with the pin's two args (typed values map
171
+ * with defaults included, generated string with defaults excluded); its return string parses into
172
+ * the RN transform array in authored order — prepend/append/reorder all expressible. A throwing
173
+ * template or an off-grammar return is a severity refusal on a LIVE UI callback (never a UI
174
+ * throw — a terminate on device): the template's output is refused, the canonical composition
175
+ * paints, and the failure reports ONCE per component through scheduleOnRN (the unmapped-key
176
+ * precedent: UI-global dedupe because module state is cloned per worklet materialization).
177
+ */
178
+ // The refusal ledger cap (review minor 1) — mirrors the projection-radius ledger's law. DECLARED
179
+ // ABOVE its reader: `composeTransformWithTemplate` is a worklet, and a worklet's captured-argument
180
+ // object is evaluated at its own declaration position. Sitting below the reader, this const was
181
+ // captured as `undefined`, so `ids.length >= undefined` was always false and the eviction this
182
+ // bound exists to trigger never ran — the ledger grew without limit while its comment claimed it
183
+ // was "bounded like every cache". Pinned by check-worklet-forward-refs.
184
+ const MAX_TRANSFORM_TEMPLATE_REFUSAL_IDS = 256
185
+
186
+ export function composeTransformWithTemplate(
187
+ values: Readonly<Record<string, number>>,
188
+ template: TransformTemplate,
189
+ componentId: string,
190
+ ): Record<string, number | string>[] {
191
+ 'worklet'
192
+ // Driver-registry skew must never vanish silently because a template owns composition
193
+ // (review minor 2): surface unmapped keys through the SAME refusal channel as the canonical
194
+ // lane and keep them out of the template args (no typeable unit exists for them).
195
+ const mapped: Record<string, number> = {}
196
+ const authored = Object.keys(values)
197
+ for (let i = 0; i < authored.length; i++) {
198
+ const key = authored[i]!
199
+ if (key === 'pathRotation') continue
200
+ if (ORDER_INDEX[key] === undefined) {
201
+ const refusalGlobal = globalThis as UnmappedTransformKeyRefusalUiGlobal
202
+ if (refusalGlobal.__nativeMotionUnmappedTransformKeyRefusals === undefined)
203
+ refusalGlobal.__nativeMotionUnmappedTransformKeyRefusals = {}
204
+ if (refusalGlobal.__nativeMotionUnmappedTransformKeyRefusals[key] === undefined) {
205
+ refusalGlobal.__nativeMotionUnmappedTransformKeyRefusals[key] = true
206
+ scheduleOnRN(reportUnmappedTransformKeyRefusal, key)
207
+ }
208
+ continue
209
+ }
210
+ mapped[key] = values[key]!
211
+ }
212
+ try {
213
+ const args = buildTransformTemplateArgs(mapped)
214
+ return parseTransformTemplateOutput(template(args.values, args.generated))
215
+ } catch (error) {
216
+ const refusalGlobal = globalThis as TransformTemplateRefusalUiGlobal
217
+ if (refusalGlobal.__nativeMotionTransformTemplateRefusals === undefined)
218
+ refusalGlobal.__nativeMotionTransformTemplateRefusals = {}
219
+ const ledger = refusalGlobal.__nativeMotionTransformTemplateRefusals
220
+ if (ledger[componentId] === undefined) {
221
+ // Bounded like every cache (review minor 1): insertion-ordered eviction past the cap —
222
+ // the OLDEST episode's entry drops (it reports again if still live), the newest always
223
+ // fires. Runs only on a first-fire per id, never per frame.
224
+ const ids = Object.keys(ledger)
225
+ if (ids.length >= MAX_TRANSFORM_TEMPLATE_REFUSAL_IDS) delete ledger[ids[0]!]
226
+ ledger[componentId] = true
227
+ scheduleOnRN(
228
+ reportTransformTemplateRefusal,
229
+ componentId,
230
+ error instanceof Error ? error.message : String(error),
231
+ )
232
+ }
233
+ return composeTransform(values)
234
+ }
235
+ }
236
+
237
+ interface TransformTemplateRefusalUiGlobal {
238
+ __nativeMotionTransformTemplateRefusals?: Record<string, true>
239
+ }
240
+
241
+ // The RN-side severity-law reporter for a template failure (T17): one JS task out of the
242
+ // scheduleOnRN dispatch extent — development throws; production reports through the error
243
+ // channel while the canonical composition paints (the template's output is refused). The UI
244
+ // side never throws.
245
+ function reportTransformTemplateRefusal(componentId: string, message: string): void {
246
+ const error = new Error(
247
+ `${componentId}: transformTemplate failed and its output was refused — the canonical ` +
248
+ `transform composition painted instead (T17). Cause: ${message}`,
249
+ )
250
+ deferUiOriginatedCallback(() => {
251
+ if (typeof __DEV__ === 'undefined' || __DEV__ !== false) throw error
252
+ console.error(error)
253
+ })
254
+ }
255
+
256
+ // The RN-side severity-law reporter for a driver-mapped-key violation (seal r7, F1): delivered
257
+ // one JS task out of the scheduleOnRN dispatch extent (the seal-r3 seam) — development throws;
258
+ // production reports through the error channel while the key is refused from the transform
259
+ // output. The UI side never throws (a bare std::terminate on device).
260
+ declare const __DEV__: boolean | undefined
261
+
262
+ function reportUnmappedTransformKeyRefusal(key: string): void {
263
+ const error = new Error(
264
+ `composeTransform: transform key '${key}' is not driver-mapped (REQ-DRIVER-027/-028) — ` +
265
+ 'refused from the transform output (iOS AUTO SIGABRT campaign).',
266
+ )
267
+ deferUiOriginatedCallback(() => {
268
+ if (typeof __DEV__ === 'undefined' || __DEV__ !== false) throw error
269
+ console.error(error)
270
+ })
271
+ }
@@ -0,0 +1,157 @@
1
+ // REQ-GESTURE-028 / REQ-API-042 (R15 D2) — the grab-time constraint MEASURE seam. The pin
2
+ // reads both page boxes synchronously when it resolves (a cache miss or its own
3
+ // measure/resize invalidation machinery); native has no synchronous page-box read, so
4
+ // MotionView's wiring measures asynchronously at EVERY grab (the freshness superset) and the
5
+ // wiring defers begin until the boxes land. The draggable's LAYOUT box is the visual frame
6
+ // MINUS the live drag value per dragged axis (the pin's projection.layout.layoutBox is
7
+ // transform-independent; the drag value applies on top of layout, so layout = visual − value).
8
+ // The constraint element is anything exposing measureInWindow (a mounted host ref); the pin's
9
+ // invariant fails loudly when it doesn't resolve. Errors here carry NO component prefix —
10
+ // ownership lands at exactly one boundary (MotionView's componentDragError, review r11
11
+ // minor 1).
12
+
13
+ import { describeValue, type Driver, type ElementHandle } from '@unrulysystems/native-motion-core'
14
+ import type { DragAxis } from '@unrulysystems/native-motion-core'
15
+ import type { ConstraintsMeasure, ConstraintsMeasureBoxes } from './dragGestureWiring'
16
+
17
+ /** Safe reject payload — never throws on hostile throwables (Object.create(null)). */
18
+ function asRejectError(error: unknown): Error {
19
+ if (error instanceof Error) return error
20
+ // describeValue is total over revoked proxies / null-prototype objects (core formatter law).
21
+ return new Error(describeValue(error))
22
+ }
23
+
24
+ // The pin's invariant surface (VEDC "drag-constraints-ref"): a ref whose `.current` is not a
25
+ // mounted measurable element at drag start is a loud failure, never a mount-time guess and
26
+ // never a silent unconstrained grab.
27
+ export function isMeasurableConstraintTarget(target: unknown): target is {
28
+ measureInWindow(callback: (x: number, y: number, width: number, height: number) => void): void
29
+ } {
30
+ return (
31
+ typeof target === 'object' &&
32
+ target !== null &&
33
+ 'measureInWindow' in target &&
34
+ typeof (target as { measureInWindow?: unknown }).measureInWindow === 'function'
35
+ )
36
+ }
37
+
38
+ interface MeasurableHost {
39
+ measureInWindow(callback: (x: number, y: number, width: number, height: number) => void): void
40
+ }
41
+
42
+ // Build the wiring's measure seam over the draggable's own measurable host ref, the driver
43
+ // (for the live per-axis values), and the element handle. `axes` are the element's DRAGGED
44
+ // axes: a live driver value exists only on those — an axis with no drag channel has no driver
45
+ // registration at all (liveFor on it THROWS, REQ-DRIVER-025), and its drag value is zero, so
46
+ // its layout span IS the visual span (the pin's transform-independent layoutBox analog).
47
+ export function createConstraintMeasure(options: {
48
+ readonly driver: Driver
49
+ readonly handle: ElementHandle
50
+ readonly axes: readonly DragAxis[]
51
+ readonly hostRef: () => MeasurableHost | null
52
+ /**
53
+ * REQ-API-043 page-point correction at the measure ingest boundary (transformPagePoint).
54
+ * Applied to measured window boxes so ref constraints under transformed ancestors match
55
+ * the pin's page-space math.
56
+ */
57
+ readonly transformPagePoint?:
58
+ | ((point: { readonly x: number; readonly y: number }) => {
59
+ readonly x: number
60
+ readonly y: number
61
+ })
62
+ | undefined
63
+ }): ConstraintsMeasure {
64
+ const { driver, handle, axes, hostRef, transformPagePoint } = options
65
+ const mapBox = (
66
+ x: number,
67
+ y: number,
68
+ width: number,
69
+ height: number,
70
+ ): { readonly min: number; readonly max: number } => {
71
+ if (transformPagePoint === undefined) {
72
+ return { min: x, max: x + width }
73
+ }
74
+ // Corner-transform so scale/offset ancestors correct both position and extent.
75
+ const tl = transformPagePoint({ x, y })
76
+ const br = transformPagePoint({ x: x + width, y: y + height })
77
+ return { min: tl.x, max: br.x }
78
+ }
79
+ const mapBoxY = (
80
+ x: number,
81
+ y: number,
82
+ width: number,
83
+ height: number,
84
+ ): { readonly min: number; readonly max: number } => {
85
+ if (transformPagePoint === undefined) {
86
+ return { min: y, max: y + height }
87
+ }
88
+ const tl = transformPagePoint({ x, y })
89
+ const br = transformPagePoint({ x: x + width, y: y + height })
90
+ return { min: tl.y, max: br.y }
91
+ }
92
+ return (container) =>
93
+ new Promise<ConstraintsMeasureBoxes>((resolve, reject) => {
94
+ const target = container.current
95
+ if (!isMeasurableConstraintTarget(target)) {
96
+ reject(
97
+ new Error(
98
+ "a dragConstraints ref must be passed to another component's ref " +
99
+ 'prop — the constraint element is unmounted or not measurable at drag start ' +
100
+ '(REQ-API-042; the pin invariant, VEDC "drag-constraints-ref").',
101
+ ),
102
+ )
103
+ return
104
+ }
105
+ const host = hostRef()
106
+ if (host === null) {
107
+ reject(new Error("the draggable's host is not measurable at drag start (REQ-API-042)."))
108
+ return
109
+ }
110
+ // The pin reads the draggable's LAYOUT box (transform-independent): the visual frame
111
+ // minus the live drag value per DRAGGED axis (layout = visual − value; the session's
112
+ // value applies on top of layout; a non-dragged axis carries no drag value at all).
113
+ // The live read happens INSIDE the measure callback (review r5 major 2): the delivered
114
+ // frame and the live value must pair at the same instant — reading live at dispatch would
115
+ // inject the UI-measure latency as a per-frame jitter into the layout box. The callbacks
116
+ // run OUTSIDE the Promise executor's scope, so a callback-time failure (a disposed driver
117
+ // after unmount — review r8 major 2) must REJECT explicitly, never escape raw and leave
118
+ // the measurement pending forever.
119
+ let draggableBox: ConstraintsMeasureBoxes['draggableBox'] | null = null
120
+ let containerBox: ConstraintsMeasureBoxes['containerBox'] | null = null
121
+ const commit = (): void => {
122
+ if (draggableBox !== null && containerBox !== null) {
123
+ resolve({ draggableBox, containerBox })
124
+ }
125
+ }
126
+ host.measureInWindow((x, y, width, height) => {
127
+ try {
128
+ const liveX = axes.includes('x') ? driver.liveFor(handle, 'x').value : 0
129
+ const liveY = axes.includes('y') ? driver.liveFor(handle, 'y').value : 0
130
+ const mappedX = mapBox(x, y, width, height)
131
+ const mappedY = mapBoxY(x, y, width, height)
132
+ draggableBox = {
133
+ x: { min: mappedX.min - liveX, max: mappedX.max - liveX },
134
+ y: { min: mappedY.min - liveY, max: mappedY.max - liveY },
135
+ }
136
+ commit()
137
+ } catch (error) {
138
+ // transformPagePoint / live read may throw — reject so the deferred grab is not
139
+ // stranded (G-INV-7). Never String(error) on hostile throwables (seal review r6 major).
140
+ reject(asRejectError(error))
141
+ }
142
+ })
143
+ target.measureInWindow((x, y, width, height) => {
144
+ try {
145
+ containerBox = {
146
+ x: mapBox(x, y, width, height),
147
+ y: mapBoxY(x, y, width, height),
148
+ }
149
+ commit()
150
+ } catch (error) {
151
+ // transformPagePoint may throw — reject so the deferred grab is not stranded
152
+ // (G-INV-7; seal review r3 major / r6 major String-safety).
153
+ reject(asRejectError(error))
154
+ }
155
+ })
156
+ })
157
+ }
@@ -0,0 +1,25 @@
1
+ // REQ-VALUETYPE-013 — split command vs deferred-pending animate snapshots under gesture hold.
2
+ // review-1785228898857-1thixx: full-scope pending must see the declarative authority (including
3
+ // held keys still in animate); commands must exclude held keys (protectedKeys).
4
+
5
+ /**
6
+ * `command` — keys safe to retarget now (not gesture-held).
7
+ * `deferredPending` — full declarative animate authority for pending abandon/rebuild (never
8
+ * held-filtered; absence means product removed the key from animate).
9
+ */
10
+ export function splitCommandAndDeferredAnimateSnapshots(
11
+ declarativeAnimate: Readonly<Record<string, unknown>>,
12
+ held: ReadonlySet<string>,
13
+ ): {
14
+ readonly command: Record<string, unknown>
15
+ readonly deferredPending: Record<string, unknown>
16
+ } {
17
+ const command: Record<string, unknown> = {}
18
+ for (const key of Object.keys(declarativeAnimate)) {
19
+ if (!held.has(key)) command[key] = declarativeAnimate[key]
20
+ }
21
+ return {
22
+ command,
23
+ deferredPending: { ...declarativeAnimate },
24
+ }
25
+ }
@@ -0,0 +1,36 @@
1
+ // The discrete keyword projection seam (T23 B, packet design law B) — the projectColorSequence
2
+ // pattern: the driver animates a [0, 100] progress channel in a shared value; the animated-style
3
+ // worklet projects the committed keyword from per-key endpoint shared values each frame. This is
4
+ // the ONE projection both the style worklet and deterministic checks call, parity-bound to core
5
+ // mixDiscrete by discreteProjection.test.ts so the two statements of the law — both of its paths,
6
+ // the mixVisibility step and the zero-slot constant — can never fork.
7
+ //
8
+ // The law is RESTATED inline rather than calling mixDiscrete because a worklet capture must stay
9
+ // primitive — no captured Set, so invisible-set membership is checked inline. Both statements are
10
+ // bound together by discreteProjection.test.ts over every pair class, so they cannot fork.
11
+ //
12
+ // ONE deliberate asymmetry: `mixDiscrete` refuses an endpoint that is not a discrete keyword, and
13
+ // this does not. A worklet must be TOTAL — a UI-runtime throw is banned (check:no-ui-throw) — and
14
+ // the controller only ever registers keyword pairs, so the guard belongs at the exported core
15
+ // symbol a caller can reach directly, not on the frame path. The parity binding covers the keyword
16
+ // family, which is the only family that reaches here.
17
+ //
18
+ // H3: a both-visible pair used to be refused before registration, and this worklet's final arm
19
+ // doubled as a total fallback for it — holding the ORIGIN, which is not what the pin does. That
20
+ // pair is now legal and reaches here, so the constant arm is explicit rather than incidental.
21
+ export function projectDiscreteStep(from: string, to: string, progress: number): string {
22
+ 'worklet'
23
+ if (from === 'none' || from === 'hidden') {
24
+ // Invisible ORIGIN appears at start: origin only while p <= 0 (the pin's origin arm — a
25
+ // both-invisible pair takes this arm first, exactly like mixDiscrete).
26
+ return progress <= 0 ? from : to
27
+ }
28
+ if (to === 'none' || to === 'hidden') {
29
+ // Invisible TARGET disappears at end: target only once p >= 100 (the internal channel is
30
+ // [0, 100]; core's mixDiscrete sees the same instant as p >= 1).
31
+ return progress >= 100 ? to : from
32
+ }
33
+ // Both visible: the pin's zero-slot template publishes the TARGET at every progress, negative
34
+ // and zero included. Not a step — nothing here reads `progress` at all.
35
+ return to
36
+ }