@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,163 @@
1
+ // REQ-API-036 / REQ-LAYOUT-021 — LayoutGroup, the layout-identity namespace (native entry).
2
+ // Web forwards the pin's own LayoutGroup (full pin nodeGroup semantics). Native owns THIS rung's
3
+ // contracted half: id namespacing of descendant layoutId registration + pin id/inherit prefix
4
+ // vocabulary + lifetime-constant context. Pin-style projection-group dirty fan-out (nodeGroup
5
+ // willUpdate batching) is DEFERRED — native layout siblings already reflow via Yoga + the CORE
6
+ // layout-transition path without a projection-node membership graph (see Finding 2026-07-22 r1).
7
+ // Owner identity is always `<LayoutGroup>` (REQ-API-014).
8
+
9
+ import { createContext, useContext, useRef, type ReactNode } from 'react'
10
+ import { describeValue } from '@unrulysystems/native-motion-core'
11
+ import { ambientNativeSeverity, consoleReporter } from './severity'
12
+ import type { NativeSeverity } from './motionViewController'
13
+
14
+ /** Pin vocabulary: boolean | "id" (default true). */
15
+ export type LayoutGroupInherit = boolean | 'id'
16
+
17
+ export interface LayoutGroupProps {
18
+ readonly children?: ReactNode
19
+ readonly id?: string | undefined
20
+ readonly inherit?: LayoutGroupInherit | undefined
21
+ }
22
+
23
+ /**
24
+ * Id-scope context only for this rung. The pin also carries a projection `group` (nodeGroup);
25
+ * native defers that half — sibling layout coordination rides the host layout path.
26
+ */
27
+ export interface LayoutGroupContextValue {
28
+ readonly id: string | undefined
29
+ }
30
+
31
+ const LAYOUT_GROUP_OWNER = '<LayoutGroup>' as const
32
+
33
+ const DEFAULT_CONTEXT: LayoutGroupContextValue = Object.freeze({
34
+ id: undefined,
35
+ })
36
+
37
+ export const LayoutGroupContext = createContext<LayoutGroupContextValue>(DEFAULT_CONTEXT)
38
+
39
+ export function useLayoutGroup(): LayoutGroupContextValue {
40
+ return useContext(LayoutGroupContext)
41
+ }
42
+
43
+ /**
44
+ * Pin law retained as a pure classifier for tests and future group wiring: only `true` inherits
45
+ * the parent projection group. This rung does not mint or consume projection groups.
46
+ */
47
+ export function shouldInheritGroup(inherit: LayoutGroupInherit): boolean {
48
+ return inherit === true
49
+ }
50
+
51
+ export function shouldInheritId(inherit: LayoutGroupInherit): boolean {
52
+ return inherit === true || inherit === 'id'
53
+ }
54
+
55
+ /**
56
+ * Resolve this group's public id scope against an upstream group id (pin LayoutGroup laws):
57
+ * when inherit admits id inheritance and an upstream id exists, prefix
58
+ * (`upstream + "-" + id` or bare upstream when the child omits id); otherwise the local id.
59
+ */
60
+ export function resolveLayoutGroupId(
61
+ id: string | undefined,
62
+ inherit: LayoutGroupInherit,
63
+ upstreamId: string | undefined,
64
+ ): string | undefined {
65
+ if (shouldInheritId(inherit) && upstreamId !== undefined && upstreamId.length > 0) {
66
+ return id !== undefined && id.length > 0 ? `${upstreamId}-${id}` : upstreamId
67
+ }
68
+ return id
69
+ }
70
+
71
+ /**
72
+ * Pin motion host law: when a group id is in scope, registry identity is
73
+ * `groupId + "-" + layoutId`; otherwise the authored layoutId is used as-is.
74
+ */
75
+ export function resolveNamespacedLayoutId(
76
+ groupId: string | undefined,
77
+ layoutId: string | undefined,
78
+ ): string | undefined {
79
+ if (layoutId === undefined) return undefined
80
+ if (groupId !== undefined && groupId.length > 0) return `${groupId}-${layoutId}`
81
+ return layoutId
82
+ }
83
+
84
+ export interface LayoutGroupGate {
85
+ readonly severity: NativeSeverity
86
+ readonly report: (error: Error) => void
87
+ }
88
+
89
+ const ACCEPTED_PROP_NAMES = new Set(['children', 'id', 'inherit'])
90
+
91
+ function refuse(gate: LayoutGroupGate, message: string): void {
92
+ const error = new Error(message)
93
+ if (gate.severity === 'development') throw error
94
+ gate.report(error)
95
+ }
96
+
97
+ export function gateLayoutGroupId(value: unknown, gate: LayoutGroupGate): string | undefined {
98
+ if (value === undefined) return undefined
99
+ if (typeof value !== 'string' || value.length === 0) {
100
+ refuse(
101
+ gate,
102
+ `${LAYOUT_GROUP_OWNER}: invalid id = ${describeValue(value)} — expected a non-empty string ` +
103
+ '(REQ-API-036).',
104
+ )
105
+ return undefined
106
+ }
107
+ return value
108
+ }
109
+
110
+ export function gateLayoutGroupInherit(
111
+ value: unknown,
112
+ gate: LayoutGroupGate,
113
+ ): LayoutGroupInherit | undefined {
114
+ if (value === undefined) return undefined
115
+ if (value === true || value === false || value === 'id') return value
116
+ refuse(
117
+ gate,
118
+ `${LAYOUT_GROUP_OWNER}: invalid inherit = ${describeValue(value)} — expected ` +
119
+ `true | false | "id" (REQ-API-036).`,
120
+ )
121
+ return undefined
122
+ }
123
+
124
+ export function gateLayoutGroupUnknownProps(
125
+ rest: Readonly<Record<string, unknown>>,
126
+ gate: LayoutGroupGate,
127
+ ): void {
128
+ for (const key of Object.keys(rest)) {
129
+ if (rest[key] === undefined) continue
130
+ if (ACCEPTED_PROP_NAMES.has(key)) continue
131
+ refuse(
132
+ gate,
133
+ `${LAYOUT_GROUP_OWNER}: prop '${key}' is outside the contracted LayoutGroup surface ` +
134
+ '(REQ-API-036: id/inherit/children) — it fails loud rather than silently doing nothing.',
135
+ )
136
+ }
137
+ }
138
+
139
+ export function LayoutGroup({ children, id, inherit, ...rest }: LayoutGroupProps) {
140
+ const severity = ambientNativeSeverity()
141
+ const gate: LayoutGroupGate = { severity, report: consoleReporter }
142
+
143
+ gateLayoutGroupUnknownProps(rest as Record<string, unknown>, gate)
144
+ const gatedId = gateLayoutGroupId(id, gate)
145
+ // Default inherit is true (pin). Production refusal of a bad inherit falls back to default
146
+ // rather than inventing a third vocabulary.
147
+ const gatedInherit = gateLayoutGroupInherit(inherit, gate) ?? true
148
+
149
+ const parent = useContext(LayoutGroupContext)
150
+
151
+ // Lifetime-constant context (pin: useRef + null check — id scope never flips on re-render).
152
+ // inherit true and "id" both inherit the id prefix this rung; they differ only on the deferred
153
+ // projection-group half (web re-export still applies the full pin distinction).
154
+ const contextRef = useRef<LayoutGroupContextValue | null>(null)
155
+ if (contextRef.current === null) {
156
+ const resolvedId = resolveLayoutGroupId(gatedId, gatedInherit, parent.id)
157
+ contextRef.current = Object.freeze({ id: resolvedId })
158
+ }
159
+
160
+ return (
161
+ <LayoutGroupContext.Provider value={contextRef.current}>{children}</LayoutGroupContext.Provider>
162
+ )
163
+ }
@@ -0,0 +1,400 @@
1
+ // REQ-API-035 — MotionConfig, the tree-wide configuration provider (native entry).
2
+ // Web forwards the pin's own MotionConfig; native implements the context so transition
3
+ // defaults and reducedMotion reach every Motion element in the subtree through the shared
4
+ // controller lane. Owner identity is always `<MotionConfig>` (REQ-API-014).
5
+
6
+ import {
7
+ createContext,
8
+ useContext,
9
+ useEffect,
10
+ useMemo,
11
+ useRef,
12
+ useState,
13
+ type ReactNode,
14
+ } from 'react'
15
+ import { AccessibilityInfo } from 'react-native'
16
+ import {
17
+ captureTransition,
18
+ describeValue,
19
+ validateTransitionRefusal,
20
+ type Transition,
21
+ } from '@unrulysystems/native-motion-core'
22
+ import { normalizeTransitionEaseAlias } from '@unrulysystems/native-motion-core/internal-driver'
23
+ import { ambientNativeSeverity, consoleReporter } from './severity'
24
+ import type { NativeSeverity } from './motionViewController'
25
+
26
+ export type ReducedMotionConfig = 'always' | 'never' | 'user'
27
+
28
+ /** Config-lane transition: the pin's `inherit` merge flag is stripped before the property lane. */
29
+ export type MotionConfigTransition = Transition & { readonly inherit?: boolean }
30
+
31
+ export type TransformPagePoint = (point: { readonly x: number; readonly y: number }) => {
32
+ readonly x: number
33
+ readonly y: number
34
+ }
35
+
36
+ export interface MotionConfigContextValue {
37
+ readonly transition: Transition | undefined
38
+ readonly reducedMotion: ReducedMotionConfig
39
+ readonly isStatic: boolean
40
+ readonly skipAnimations: boolean
41
+ /** Resolved preference for the current config: always/user+platform → true; never → false. */
42
+ readonly shouldReduceMotion: boolean
43
+ /**
44
+ * R15 D3 (REQ-API-043): page-point correction at the recognizer-ingest boundary (pin
45
+ * transformPagePoint). Applied to hub start points and measure reads under transformed
46
+ * ancestors — never an accepted-but-inert prop (G-INV-8).
47
+ */
48
+ readonly transformPagePoint: TransformPagePoint | undefined
49
+ }
50
+
51
+ export interface MotionConfigProps {
52
+ readonly children?: ReactNode
53
+ readonly transition?: MotionConfigTransition | undefined
54
+ readonly reducedMotion?: ReducedMotionConfig | undefined
55
+ readonly nonce?: string | undefined
56
+ readonly isStatic?: boolean | undefined
57
+ /** R15 D3 (REQ-API-043): page-point correction (pin MotionConfig transformPagePoint). */
58
+ readonly transformPagePoint?: TransformPagePoint | undefined
59
+ readonly skipAnimations?: boolean | undefined
60
+ }
61
+
62
+ const MOTION_CONFIG_OWNER = '<MotionConfig>' as const
63
+
64
+ const DEFAULT_CONTEXT: MotionConfigContextValue = Object.freeze({
65
+ transition: undefined,
66
+ reducedMotion: 'never',
67
+ isStatic: false,
68
+ skipAnimations: false,
69
+ shouldReduceMotion: false,
70
+ transformPagePoint: undefined,
71
+ })
72
+
73
+ export const MotionConfigContext = createContext<MotionConfigContextValue>(DEFAULT_CONTEXT)
74
+
75
+ export function useMotionConfig(): MotionConfigContextValue {
76
+ return useContext(MotionConfigContext)
77
+ }
78
+
79
+ // Lifetime-constant value (the pin's useConstant law for isStatic — must not flip between renders
80
+ // because it changes how many hooks Motion hosts fire).
81
+ function useConstant<T>(init: () => T): T {
82
+ const ref = useRef<{ readonly value: T } | null>(null)
83
+ if (ref.current === null) ref.current = { value: init() }
84
+ return ref.current.value
85
+ }
86
+
87
+ /**
88
+ * Nested config transition resolution (pin `resolveTransition` from motion-dom@12.42.2):
89
+ * when the child transition carries `inherit: true`, merge parent then child overrides;
90
+ * otherwise the child transition replaces the parent (or is undefined).
91
+ * The `inherit` flag is stripped before the value reaches the property lane.
92
+ */
93
+ /** Strip only `inherit`; materialize every other own key once (getters fire here, G-INV-8). */
94
+ function stripInheritFlag(raw: MotionConfigTransition): Transition {
95
+ const source = raw as Record<PropertyKey, unknown>
96
+ const out: Record<PropertyKey, unknown> = Object.create(null)
97
+ let hasLaneKey = false
98
+ for (const key of Reflect.ownKeys(raw)) {
99
+ if (key === 'inherit') continue
100
+ hasLaneKey = true
101
+ // One read of the own property (accessor or data) — never copy descriptor.value of a getter.
102
+ const value = source[key]
103
+ Object.defineProperty(out, key, {
104
+ value,
105
+ enumerable: Object.getOwnPropertyDescriptor(raw, key)?.enumerable === true,
106
+ configurable: true,
107
+ writable: true,
108
+ })
109
+ }
110
+ return (hasLaneKey ? out : Object.freeze({})) as Transition
111
+ }
112
+
113
+ export function resolveConfigTransition(
114
+ transition: MotionConfigTransition | undefined,
115
+ parentTransition: Transition | undefined,
116
+ ): Transition | undefined {
117
+ if (transition === undefined) return parentTransition
118
+ const inherit = transition.inherit === true
119
+ const rest = stripInheritFlag(transition)
120
+ // Family-2: canonicalize easings→ease before merge so parent ease cannot clobber child easings
121
+ // (and dual-key inheritance never leaves the pin-facing shape as easings-only).
122
+ const parent =
123
+ parentTransition === undefined ? undefined : normalizeTransitionEaseAlias(parentTransition)
124
+ const childKeys = Reflect.ownKeys(rest as object)
125
+ const child =
126
+ childKeys.length === 0 ? undefined : normalizeTransitionEaseAlias(rest as Transition)
127
+ if (inherit && parent !== undefined) {
128
+ if (child === undefined) return parent
129
+ // Merge own keys only (null-prototype) — no Object.prototype pollution.
130
+ const merged: Record<PropertyKey, unknown> = Object.create(null)
131
+ for (const key of Reflect.ownKeys(parent as object)) {
132
+ const d = Object.getOwnPropertyDescriptor(parent as object, key)
133
+ if (d !== undefined)
134
+ Object.defineProperty(merged, key, { ...d, configurable: true, writable: true })
135
+ }
136
+ for (const key of Reflect.ownKeys(child as object)) {
137
+ const d = Object.getOwnPropertyDescriptor(child as object, key)
138
+ if (d !== undefined)
139
+ Object.defineProperty(merged, key, { ...d, configurable: true, writable: true })
140
+ }
141
+ return Object.freeze(merged) as Transition
142
+ }
143
+ // Bare `{ inherit: true }` with no parent / no lane keys → parent or undefined.
144
+ if (child === undefined) {
145
+ return inherit ? parent : undefined
146
+ }
147
+ return child
148
+ }
149
+
150
+ const REDUCED_MOTION_VALUES = new Set<string>(['always', 'never', 'user'])
151
+
152
+ const ACCEPTED_PROP_NAMES = new Set([
153
+ 'children',
154
+ 'transition',
155
+ 'reducedMotion',
156
+ 'nonce',
157
+ 'isStatic',
158
+ 'skipAnimations',
159
+ 'transformPagePoint',
160
+ ])
161
+
162
+ export interface MotionConfigGate {
163
+ readonly severity: NativeSeverity
164
+ readonly report: (error: Error) => void
165
+ }
166
+
167
+ function refuse(gate: MotionConfigGate, message: string): void {
168
+ const error = new Error(message)
169
+ if (gate.severity === 'development') throw error
170
+ gate.report(error)
171
+ }
172
+
173
+ /** Capture + validate a config transition; production refuses the prop (returns undefined). */
174
+ export function gateMotionConfigTransition(
175
+ value: unknown,
176
+ gate: MotionConfigGate,
177
+ ): Transition | undefined {
178
+ if (value === undefined) return undefined
179
+ const proto =
180
+ typeof value === 'object' && value !== null ? Object.getPrototypeOf(value) : undefined
181
+ if (
182
+ typeof value !== 'object' ||
183
+ value === null ||
184
+ Array.isArray(value) ||
185
+ (proto !== Object.prototype && proto !== null)
186
+ ) {
187
+ refuse(
188
+ gate,
189
+ `${MOTION_CONFIG_OWNER}: invalid transition = ${describeValue(value)} — a transition must ` +
190
+ 'be a plain object of transition options (REQ-API-035).',
191
+ )
192
+ return undefined
193
+ }
194
+ // Strip only inherit via Reflect.ownKeys — never object-rest / Object.keys (non-enumerable
195
+ // easings and invalid own keys must still reach captureTransition + validate, G-INV-8).
196
+ const raw = value as MotionConfigTransition
197
+ // One-read capture of inherit (373eu6): validation and merge must observe the same truth —
198
+ // a stateful accessor cannot bypass the boolean check by returning different values.
199
+ const hasInherit = Object.hasOwn(raw, 'inherit')
200
+ const inheritSnapshot: unknown = hasInherit ? raw.inherit : undefined
201
+ // REQ-API-035(e) / xxytww: inherit is a boolean nesting switch — non-boolean fails loud.
202
+ if (hasInherit && inheritSnapshot !== undefined && typeof inheritSnapshot !== 'boolean') {
203
+ refuse(
204
+ gate,
205
+ `${MOTION_CONFIG_OWNER}: invalid inherit = ${describeValue(inheritSnapshot)} — expected a boolean ` +
206
+ '(REQ-API-035).',
207
+ )
208
+ if (gate.severity !== 'development') return undefined
209
+ }
210
+ const laneOptions = stripInheritFlag(raw)
211
+ const snapshot =
212
+ Reflect.ownKeys(laneOptions as object).length === 0
213
+ ? (Object.freeze({}) as Transition)
214
+ : captureTransition(laneOptions as Transition)
215
+ const refusal = validateTransitionRefusal(snapshot, { componentId: MOTION_CONFIG_OWNER })
216
+ if (refusal !== null) {
217
+ if (gate.severity === 'development') throw refusal
218
+ gate.report(refusal)
219
+ return undefined
220
+ }
221
+ // Family-2: canonicalize easings→ease on the accepted snapshot (deep-captured by captureTransition).
222
+ const normalized = normalizeTransitionEaseAlias(snapshot)
223
+ // Preserve inherit on the resolved shape only for nested merge — caller uses resolveConfigTransition.
224
+ if (inheritSnapshot === true) {
225
+ const withInherit: Record<PropertyKey, unknown> = Object.create(null)
226
+ for (const key of Reflect.ownKeys(normalized as object)) {
227
+ const d = Object.getOwnPropertyDescriptor(normalized as object, key)
228
+ if (d !== undefined)
229
+ Object.defineProperty(withInherit, key, { ...d, configurable: true, writable: true })
230
+ }
231
+ Object.defineProperty(withInherit, 'inherit', {
232
+ value: true,
233
+ enumerable: true,
234
+ configurable: true,
235
+ writable: true,
236
+ })
237
+ return Object.freeze(withInherit) as MotionConfigTransition
238
+ }
239
+ return normalized
240
+ }
241
+
242
+ export function gateMotionConfigReducedMotion(
243
+ value: unknown,
244
+ gate: MotionConfigGate,
245
+ ): ReducedMotionConfig | undefined {
246
+ if (value === undefined) return undefined
247
+ if (typeof value !== 'string' || !REDUCED_MOTION_VALUES.has(value)) {
248
+ refuse(
249
+ gate,
250
+ `${MOTION_CONFIG_OWNER}: invalid reducedMotion = ${describeValue(value)} — expected ` +
251
+ `"always" | "never" | "user" (REQ-API-035).`,
252
+ )
253
+ return undefined
254
+ }
255
+ return value as ReducedMotionConfig
256
+ }
257
+
258
+ export function gateMotionConfigBoolean(
259
+ propName: string,
260
+ value: unknown,
261
+ gate: MotionConfigGate,
262
+ ): boolean | undefined {
263
+ if (value === undefined) return undefined
264
+ if (typeof value !== 'boolean') {
265
+ refuse(
266
+ gate,
267
+ `${MOTION_CONFIG_OWNER}: invalid ${propName} = ${describeValue(value)} — expected a boolean ` +
268
+ '(REQ-API-035).',
269
+ )
270
+ return undefined
271
+ }
272
+ return value
273
+ }
274
+
275
+ /** Native refuses nonce (DOM CSP — WEB-ONLY-BY-LAW). */
276
+ export function gateMotionConfigNonce(value: unknown, gate: MotionConfigGate): void {
277
+ if (value === undefined) return
278
+ refuse(
279
+ gate,
280
+ `${MOTION_CONFIG_OWNER}: nonce is WEB-ONLY-BY-LAW (a DOM CSP attribute with no native analog) ` +
281
+ '— it fails loud on the native entry (REQ-API-035).',
282
+ )
283
+ }
284
+
285
+ export function gateMotionConfigUnknownProps(
286
+ rest: Readonly<Record<string, unknown>>,
287
+ gate: MotionConfigGate,
288
+ ): void {
289
+ for (const key of Object.keys(rest)) {
290
+ if (rest[key] === undefined) continue
291
+ if (ACCEPTED_PROP_NAMES.has(key)) continue
292
+ refuse(
293
+ gate,
294
+ `${MOTION_CONFIG_OWNER}: prop '${key}' is outside the contracted MotionConfig surface ` +
295
+ '(REQ-API-035: transition/reducedMotion/isStatic/skipAnimations/nonce/children) — ' +
296
+ 'it fails loud rather than silently doing nothing.',
297
+ )
298
+ }
299
+ }
300
+
301
+ function gateTransformPagePoint(
302
+ value: unknown,
303
+ gate: MotionConfigGate,
304
+ ): TransformPagePoint | undefined {
305
+ if (value === undefined) return undefined
306
+ if (typeof value !== 'function') {
307
+ refuse(
308
+ gate,
309
+ `${MOTION_CONFIG_OWNER}: invalid transformPagePoint = ${describeValue(value)} — expected a ` +
310
+ 'function (point) => point (REQ-API-043).',
311
+ )
312
+ return undefined
313
+ }
314
+ return value as TransformPagePoint
315
+ }
316
+
317
+ export function MotionConfig({
318
+ children,
319
+ transition,
320
+ reducedMotion,
321
+ nonce,
322
+ isStatic,
323
+ skipAnimations,
324
+ transformPagePoint,
325
+ ...rest
326
+ }: MotionConfigProps) {
327
+ const severity = ambientNativeSeverity()
328
+ const gate: MotionConfigGate = { severity, report: consoleReporter }
329
+
330
+ gateMotionConfigUnknownProps(rest as Record<string, unknown>, gate)
331
+ gateMotionConfigNonce(nonce, gate)
332
+
333
+ const parent = useContext(MotionConfigContext)
334
+ const gatedTransition = gateMotionConfigTransition(transition, gate)
335
+ const resolvedTransition = resolveConfigTransition(
336
+ gatedTransition as MotionConfigTransition | undefined,
337
+ parent.transition,
338
+ )
339
+ const gatedReduced = gateMotionConfigReducedMotion(reducedMotion, gate) ?? parent.reducedMotion
340
+ const gatedIsStaticRaw = gateMotionConfigBoolean('isStatic', isStatic, gate)
341
+ // Lifetime-constant isStatic (pin useConstant law).
342
+ const frozenIsStatic = useConstant(() =>
343
+ gatedIsStaticRaw !== undefined ? gatedIsStaticRaw : parent.isStatic,
344
+ )
345
+ const gatedSkip = gateMotionConfigBoolean('skipAnimations', skipAnimations, gate)
346
+ const resolvedSkip = gatedSkip !== undefined ? gatedSkip : parent.skipAnimations
347
+ const gatedTransform =
348
+ gateTransformPagePoint(transformPagePoint, gate) ?? parent.transformPagePoint
349
+
350
+ // Platform reduced-motion preference — only consulted for "user".
351
+ const [userPrefersReduce, setUserPrefersReduce] = useState(false)
352
+ useEffect(() => {
353
+ if (gatedReduced !== 'user') {
354
+ setUserPrefersReduce(false)
355
+ return
356
+ }
357
+ let cancelled = false
358
+ void AccessibilityInfo.isReduceMotionEnabled().then((enabled) => {
359
+ if (!cancelled) setUserPrefersReduce(enabled === true)
360
+ })
361
+ const subscription = AccessibilityInfo.addEventListener(
362
+ 'reduceMotionChanged',
363
+ (enabled: boolean) => {
364
+ if (!cancelled) setUserPrefersReduce(enabled === true)
365
+ },
366
+ )
367
+ return () => {
368
+ cancelled = true
369
+ // RN subscription shape: { remove() } on modern; function on older.
370
+ if (subscription !== undefined && typeof subscription.remove === 'function') {
371
+ subscription.remove()
372
+ }
373
+ }
374
+ }, [gatedReduced])
375
+
376
+ const shouldReduceMotion =
377
+ gatedReduced === 'always' || (gatedReduced === 'user' && userPrefersReduce)
378
+
379
+ const context = useMemo<MotionConfigContextValue>(
380
+ () =>
381
+ Object.freeze({
382
+ transition: resolvedTransition,
383
+ reducedMotion: gatedReduced,
384
+ isStatic: frozenIsStatic,
385
+ skipAnimations: resolvedSkip,
386
+ shouldReduceMotion,
387
+ transformPagePoint: gatedTransform,
388
+ }),
389
+ [
390
+ resolvedTransition,
391
+ gatedReduced,
392
+ frozenIsStatic,
393
+ resolvedSkip,
394
+ shouldReduceMotion,
395
+ gatedTransform,
396
+ ],
397
+ )
398
+
399
+ return <MotionConfigContext.Provider value={context}>{children}</MotionConfigContext.Provider>
400
+ }