@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,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
|
+
}
|