@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,3180 @@
|
|
|
1
|
+
// Motion.View's brain (specs/M2.2-BUILD-PACKET.md §pinned semantics): validate → resolve → diff →
|
|
2
|
+
// driver commands, factored OUT of the React component so the behavioral checks drive it
|
|
3
|
+
// deterministically against the manual-stepping driver (no React timing in the verification path —
|
|
4
|
+
// the same pure-interior/thin-binding split the core uses). The component wrapper owns only React
|
|
5
|
+
// concerns (hook driver, animated style, prop plumbing); everything semantic lives here and calls
|
|
6
|
+
// core's exported pipeline — no forked resolution logic (REQ-API-017 is the seam this reuses).
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
InvalidTransitionError,
|
|
10
|
+
validateTransition,
|
|
11
|
+
assertTargetShape,
|
|
12
|
+
captureTransition,
|
|
13
|
+
describeValue,
|
|
14
|
+
getDefaultTransition,
|
|
15
|
+
hostCapabilities,
|
|
16
|
+
DiscreteHostBaseError,
|
|
17
|
+
InvalidTargetError,
|
|
18
|
+
isAuto,
|
|
19
|
+
isDiscreteKeyword,
|
|
20
|
+
parseColor,
|
|
21
|
+
parseValue,
|
|
22
|
+
isLengthTarget,
|
|
23
|
+
resolveLengthToPx,
|
|
24
|
+
resolveStartValue,
|
|
25
|
+
springKeyframeCountRefusal,
|
|
26
|
+
targetShapeRefusal,
|
|
27
|
+
transformColor,
|
|
28
|
+
validateTarget,
|
|
29
|
+
validateTargetRefusal,
|
|
30
|
+
type DriverCommand,
|
|
31
|
+
type ElementHandle,
|
|
32
|
+
type HostCapabilities,
|
|
33
|
+
type LengthLayoutContext,
|
|
34
|
+
type MeasureResolveContextError,
|
|
35
|
+
type PositionalLengthAxis,
|
|
36
|
+
type PropTarget,
|
|
37
|
+
type PropTargetTo,
|
|
38
|
+
type PropTransition,
|
|
39
|
+
type ResolvedValue,
|
|
40
|
+
type RGBA,
|
|
41
|
+
type Target,
|
|
42
|
+
type Transition,
|
|
43
|
+
TARGET_PROPERTY_KEYS,
|
|
44
|
+
} from '@unrulysystems/native-motion-core'
|
|
45
|
+
import { rewritePathCommands, seedPathRotationKey } from './pathTransition'
|
|
46
|
+
import {
|
|
47
|
+
isNativeMeasureLengthKey,
|
|
48
|
+
isNativeViewMotionProp,
|
|
49
|
+
NATIVE_PROPERTY_TRANSITION_OPTIONS,
|
|
50
|
+
nativeMountTargetValueKind,
|
|
51
|
+
} from './shippedSurface'
|
|
52
|
+
import { TRANSFORM_KEY_NAMES } from './mappedKeys'
|
|
53
|
+
import { keyframeTimingRefusal } from './keyframeTiming'
|
|
54
|
+
|
|
55
|
+
const TRANSFORM_KEY_SET = new Set<string>(TRANSFORM_KEY_NAMES)
|
|
56
|
+
const TRANSITION_MAP_KEYS = new Set<string>(['default', 'layout', ...TARGET_PROPERTY_KEYS])
|
|
57
|
+
/** Instant property transition for reduced-motion / static / skip paths (pin type:false analog). */
|
|
58
|
+
const INSTANT_TRANSITION: Transition = Object.freeze({ type: 'tween', duration: 0 })
|
|
59
|
+
import {
|
|
60
|
+
captureBoundedArray,
|
|
61
|
+
capturedArrayDescription,
|
|
62
|
+
isRepeatFoldRefusal,
|
|
63
|
+
resolveTransitionForKey,
|
|
64
|
+
} from '@unrulysystems/native-motion-core/internal-driver'
|
|
65
|
+
import type { WorkletDriverBinding } from '../driver/workletDriver'
|
|
66
|
+
import { IDENTITY_DECLARATIVE_LANE_COMMAND } from './identityValueChannelLaneMarker'
|
|
67
|
+
import { projectColorSequence } from './colorProjection'
|
|
68
|
+
import { projectDiscreteStep } from './discreteProjection'
|
|
69
|
+
|
|
70
|
+
export interface MotionViewProps {
|
|
71
|
+
// First-paint state (REQ-API-010): a Target overlays the start values; `false` mounts already AT
|
|
72
|
+
// the resolved `animate` with no entrance animation; omitted → style/host-base start values.
|
|
73
|
+
readonly initial?: Target | false | undefined
|
|
74
|
+
readonly animate: Target
|
|
75
|
+
readonly transition?: Transition | undefined
|
|
76
|
+
// Static style passthrough consulted for start values (REQ-API-011). L1: numeric values plus
|
|
77
|
+
// color strings for rgba-typed keys; the style-merge policy stays a flagged §7 call.
|
|
78
|
+
readonly style?: Readonly<Record<string, number | string>>
|
|
79
|
+
// FLAG 5a (REQ-API-020): keys registered STATIC — at their style/host-base start value with no
|
|
80
|
+
// target and no entrance command — because an external lane drives them (the drag axis through
|
|
81
|
+
// the driver's write lane). The initial-only-key rule generalized; a key already covered by
|
|
82
|
+
// animate/initial registers through its normal path and this list is a no-op for it.
|
|
83
|
+
readonly staticKeys?: readonly string[]
|
|
84
|
+
/** Internal host identity used solely for element-owned validation diagnostics. */
|
|
85
|
+
readonly componentId?: MotionComponentId
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type MotionComponentId = '<Motion.View>' | '<Motion.Text>' | '<Motion.Image>'
|
|
89
|
+
|
|
90
|
+
// L1 (specs/L1-BUILD-PACKET.md §semantics 4): the endpoint-sync port the wrapper provides. Invoked
|
|
91
|
+
// with the CURRENT endpoints at every command entry (mount and each color retarget), BEFORE the
|
|
92
|
+
// driver command is issued, so the animated style's endpoint shared values are never stale when a
|
|
93
|
+
// fresh progress lands — one sync path, no lazily-reconciled state.
|
|
94
|
+
export interface MotionViewControllerHooks {
|
|
95
|
+
readonly onColorEndpoints?: (key: string, sequence: readonly RGBA[]) => void
|
|
96
|
+
// T23 B2b: the discrete lane's endpoint publication — the [from, to] keyword pair the animated
|
|
97
|
+
// style projects through (the onColorEndpoints mirror; primitive-only payload for the crossing).
|
|
98
|
+
readonly onDiscreteEndpoints?: (key: string, pair: readonly [string, string]) => void
|
|
99
|
+
// The component's severity channel (review major 42): the R8-F1 spring/keyframe-array compat refusal
|
|
100
|
+
// for INDIRECT consumers (a gesture/label array riding the element-transition fallback reaches the
|
|
101
|
+
// controller here, past its supplying gate) is reported + the property dropped in production, thrown
|
|
102
|
+
// in development. A direct controller consumer defaults to development (the loud law).
|
|
103
|
+
readonly severity?: NativeSeverity
|
|
104
|
+
readonly report?: (error: Error) => void
|
|
105
|
+
// REQ-API-035: live MotionConfig policy box (the wrapper mutates `.current` each render so a
|
|
106
|
+
// late AccessibilityInfo "user" resolution and isStatic/skipAnimations updates reach the
|
|
107
|
+
// next command without recreating the controller).
|
|
108
|
+
readonly motionPolicyBox?: {
|
|
109
|
+
current: { readonly shouldReduceMotion: boolean; readonly skipAnimations: boolean }
|
|
110
|
+
}
|
|
111
|
+
// REQ-VALUETYPE-013: live layout context for measure-resolving %/auto/simple calc length targets.
|
|
112
|
+
// Wrapper updates `.current` from parent/viewport/content onLayout; missing required fields fail loud.
|
|
113
|
+
readonly lengthLayoutContextBox?: { current: LengthLayoutContext }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface MotionViewController {
|
|
117
|
+
// Validate + resolve + seed the element and issue the mount command (one `start`, O(1)); throws
|
|
118
|
+
// InvalidTargetError on a bad target BEFORE any driver mutation (fail-closed, REQ-API-013).
|
|
119
|
+
mount(): ElementHandle
|
|
120
|
+
// Diff the RESOLVED next target against the current one; issue ONE `retarget` carrying only the
|
|
121
|
+
// changed keys, or NO command when nothing changed (unrelated re-render discipline). The return
|
|
122
|
+
// reports whether any driver command was emitted so lifecycle gates can settle a zero-delta exit
|
|
123
|
+
// synchronously instead of waiting for an onAllSettled edge that cannot occur.
|
|
124
|
+
update(next: {
|
|
125
|
+
readonly animate: Target
|
|
126
|
+
readonly transition?: Transition | undefined
|
|
127
|
+
/**
|
|
128
|
+
* Internal variant-lane execution edge. Motion restarts an element-wise equal keyframe
|
|
129
|
+
* target when its authored variant label changes, while ordinary equal-target updates
|
|
130
|
+
* remain no-ops. Scalars are deliberately unaffected.
|
|
131
|
+
*/
|
|
132
|
+
readonly restartEqualKeyframes?: boolean | undefined
|
|
133
|
+
// AnimatePresence's one removal-boundary snapshot. Only null-first targets consume it; ordinary
|
|
134
|
+
// updates keep reading the driver's current value at their own command boundary.
|
|
135
|
+
readonly removalValues?: Readonly<Record<string, ResolvedValue>> | undefined
|
|
136
|
+
/**
|
|
137
|
+
* Deferred-length pending queue scope (review-1785219035198-7rbpy5):
|
|
138
|
+
* - `'partial'` (default): gesture/label slices — never treat key absence as abandonment
|
|
139
|
+
* - `'full'`: declarative animate snapshot (host prop) — keys absent from animate are abandoned
|
|
140
|
+
*/
|
|
141
|
+
readonly animateScope?: 'partial' | 'full'
|
|
142
|
+
/**
|
|
143
|
+
* Internal — the deferred flush's own re-entry (review 356417a77aec): the flush resolves
|
|
144
|
+
* auto targets against the measure it just seeded, so the retarget invalidation must not
|
|
145
|
+
* drop that fresh measure. Only flushDeferredLengthMeasures sets this.
|
|
146
|
+
*/
|
|
147
|
+
readonly skipAutoMeasureInvalidation?: boolean
|
|
148
|
+
/**
|
|
149
|
+
* T21 (REQ-API-053) — the orchestration-computed per-child start delay (seconds; negative
|
|
150
|
+
* = elapsed-time offset). Applied as the per-key DEFAULT under any authored delay (the
|
|
151
|
+
* pin's `{ delay, ...getValueTransition }` spread, visual-element-target.ts:87-90). Set
|
|
152
|
+
* only by the variant tree's label-application lane; finite or the update fails loud.
|
|
153
|
+
*/
|
|
154
|
+
readonly orchestrationDelaySeconds?: number | undefined
|
|
155
|
+
}): boolean
|
|
156
|
+
/**
|
|
157
|
+
* First-mount element-box measures (auto, x/y %): keys registered at host base while
|
|
158
|
+
* onLayout was pending. Call after autoWidth/autoHeight land so deferred targets command
|
|
159
|
+
* without violating the fixed-at-mount key set (REQ-VALUETYPE-013).
|
|
160
|
+
*/
|
|
161
|
+
/**
|
|
162
|
+
* Flush pending measure-resolved keys. Optional `held` excludes gesture-held keys so mid-hold
|
|
163
|
+
* onLayout does not retarget protected keys (review-1785230277710-lhz6ub).
|
|
164
|
+
*/
|
|
165
|
+
flushDeferredLengthMeasures(held?: ReadonlySet<string>): boolean
|
|
166
|
+
/**
|
|
167
|
+
* Re-seed settled auto-resting AND relative-resting keys to their freshly measured values
|
|
168
|
+
* (reviews ae0a42832d85 + 7cec8ad963d8): an auto-rest key releases to the intrinsic layout at
|
|
169
|
+
* settle so content drift shows up in onLayout directly; an initial-only relative rest
|
|
170
|
+
* (%/calc/vw/vh) re-resolves its kept form against the changed element box / parent /
|
|
171
|
+
* viewport. Both re-seed INSTANT (never an animation — the element already tracks the
|
|
172
|
+
* measure). Keys mid-animation are skipped. Returns whether any re-seed commanded.
|
|
173
|
+
*/
|
|
174
|
+
remeasureAutoRests(): boolean
|
|
175
|
+
/**
|
|
176
|
+
* Rebuild deferred-length pending from a declarative animate snapshot without issuing
|
|
177
|
+
* driver commands or re-running keyframe/element-transition compatibility (label full
|
|
178
|
+
* projection after per-label updates — review-1785226634574-2gxseb).
|
|
179
|
+
*/
|
|
180
|
+
syncDeferredLengthPending(next: {
|
|
181
|
+
readonly animate: Target
|
|
182
|
+
readonly animateScope?: 'partial' | 'full'
|
|
183
|
+
}): void
|
|
184
|
+
// The projected rgba() string a color key currently renders (L1, specs/L1-BUILD-PACKET.md
|
|
185
|
+
// §semantics 3): the SAME projectColor seam the animated style binds, evaluated at the driver's
|
|
186
|
+
// live progress — the deterministic observable the checks drive.
|
|
187
|
+
currentColor(key: string): string
|
|
188
|
+
// The discrete deterministic observable (T23 B2b): the keyword displayed at the live progress,
|
|
189
|
+
// through the SAME projectDiscreteStep seam the animated style binds.
|
|
190
|
+
currentDiscrete(key: string): string
|
|
191
|
+
/**
|
|
192
|
+
* T23 B3 (pin visual-element-target.ts:159-168): JUMP-apply the latest target's transitionEnd
|
|
193
|
+
* values — instantly, never animated. Every key must be mounted (fixed-at-mount law); each
|
|
194
|
+
* family jumps on its own lane (numeric INSTANT retarget; discrete/color settled re-seed +
|
|
195
|
+
* INSTANT progress start on an EQUAL pair, which projects the jumped value at any progress) and
|
|
196
|
+
* moves its diff base so a later identical animate target is a no-op.
|
|
197
|
+
*/
|
|
198
|
+
applyTransitionEnd(target: Readonly<Record<string, unknown>>): void
|
|
199
|
+
// T23 C: project the driver marshal's raw committed record (progress lanes) to the public
|
|
200
|
+
// latest-values record the onUpdate callback receives. Pure JS; shares the currentColor /
|
|
201
|
+
// currentDiscrete projection seams.
|
|
202
|
+
projectLatestValues(
|
|
203
|
+
raw: Readonly<Record<string, number>>,
|
|
204
|
+
): Readonly<Record<string, string | number>>
|
|
205
|
+
// Immutable removal-boundary snapshot for Presence (REQ-PRESENCE-020). Reads every registered
|
|
206
|
+
// property's actual committed host value; colors are projected to the string currently displayed.
|
|
207
|
+
committedValues(keys: readonly string[]): Readonly<Record<string, ResolvedValue>>
|
|
208
|
+
/** Fresh pin-shaped maps for functional animation definitions. */
|
|
209
|
+
variantValueState(): {
|
|
210
|
+
readonly current: Readonly<Record<string, ResolvedValue>>
|
|
211
|
+
readonly velocity: Readonly<Record<string, number>>
|
|
212
|
+
}
|
|
213
|
+
// Deactivate before release (REQ-DRIVER-020 window-shrink).
|
|
214
|
+
unmount(): void
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// The native host declaration, built from the registry so it can never drift from membership — the
|
|
218
|
+
// same construction the conformance scenarios use.
|
|
219
|
+
const NATIVE_HOST: HostCapabilities = hostCapabilities('native', ['universal', 'native-extension'])
|
|
220
|
+
|
|
221
|
+
// The single validation entry the wrapper AND the controller share, so fail-loud ordering can never
|
|
222
|
+
// fork: an invalid/unknown/off-host key throws core's InvalidTargetError (REQ-API-013/014) BEFORE any
|
|
223
|
+
// other wrapper concern (review cycle 2: a scope guard running first swallowed the pipeline error).
|
|
224
|
+
// The property lane's full transition law (r9 major 762e9d0a4c77): core executability PLUS
|
|
225
|
+
// this lane's executable option set — an option the lane cannot execute (velocity) fails loud on a
|
|
226
|
+
// direct consumer instead of riding along inert (G-INV-8). Transition maps resolve to the actual
|
|
227
|
+
// mounted property keys before this lane-specific option check; core still validates every map branch.
|
|
228
|
+
function validatePropertyLaneTransition(
|
|
229
|
+
transition: Transition | undefined,
|
|
230
|
+
componentId: MotionComponentId,
|
|
231
|
+
propertyKeys: readonly string[] = ['__root__'],
|
|
232
|
+
): void {
|
|
233
|
+
validateTransition(transition, { componentId })
|
|
234
|
+
if (transition === undefined) return
|
|
235
|
+
if (
|
|
236
|
+
Reflect.ownKeys(transition).some(
|
|
237
|
+
(key) => typeof key === 'string' && TRANSITION_MAP_KEYS.has(key),
|
|
238
|
+
)
|
|
239
|
+
) {
|
|
240
|
+
const selectedKeys = propertyKeys.length === 0 ? ['__root__'] : propertyKeys
|
|
241
|
+
for (const key of new Set(selectedKeys)) {
|
|
242
|
+
validatePropertyLaneTransition(resolveTransitionForKey(transition, key), componentId)
|
|
243
|
+
}
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
for (const option of Reflect.ownKeys(transition)) {
|
|
247
|
+
if (typeof option !== 'string' || TRANSITION_MAP_KEYS.has(option)) continue // core's closed-schema validator already rejects symbols
|
|
248
|
+
const value = (transition as Record<string, unknown>)[option]
|
|
249
|
+
if (value === undefined) continue // explicitly-undefined reads as absent (core semantics)
|
|
250
|
+
if (!NATIVE_PROPERTY_TRANSITION_OPTIONS.has(option)) {
|
|
251
|
+
throw new InvalidTransitionError(
|
|
252
|
+
componentId,
|
|
253
|
+
option,
|
|
254
|
+
value,
|
|
255
|
+
`not executable by the property lane (it drives: ` +
|
|
256
|
+
`${NATIVE_PROPERTY_TRANSITION_OPTIONS.options.join(', ')}) — a silently inert ` +
|
|
257
|
+
'option is refused (G-INV-8, shippedSurface.ts)',
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
for (const mapKey of Reflect.ownKeys(transition)) {
|
|
262
|
+
if (typeof mapKey !== 'string' || !TRANSITION_MAP_KEYS.has(mapKey)) continue
|
|
263
|
+
const bag = (transition as Record<string, unknown>)[mapKey]
|
|
264
|
+
if (typeof bag !== 'object' || bag === null || Array.isArray(bag)) continue
|
|
265
|
+
for (const option of Reflect.ownKeys(bag)) {
|
|
266
|
+
if (typeof option !== 'string' || option === 'inherit' || TRANSITION_MAP_KEYS.has(option))
|
|
267
|
+
continue
|
|
268
|
+
const value = (bag as Record<string, unknown>)[option]
|
|
269
|
+
if (value === undefined || NATIVE_PROPERTY_TRANSITION_OPTIONS.has(option)) continue
|
|
270
|
+
throw new InvalidTransitionError(
|
|
271
|
+
componentId,
|
|
272
|
+
option,
|
|
273
|
+
value,
|
|
274
|
+
'not executable by the property lane (it drives: ' +
|
|
275
|
+
NATIVE_PROPERTY_TRANSITION_OPTIONS.options.join(', ') +
|
|
276
|
+
') — a silently inert option is refused (G-INV-8, shippedSurface.ts)',
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function validateAgainstNativeHost(
|
|
283
|
+
target: Readonly<Record<string, unknown>>,
|
|
284
|
+
componentId: MotionComponentId = '<Motion.View>',
|
|
285
|
+
): void {
|
|
286
|
+
validateTarget(target, NATIVE_HOST, { componentId })
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Capture the per-call transition ONCE and validate the SNAPSHOT (review majors 51/61): a malformed
|
|
290
|
+
// non-object shape (null is never absence) is refused on the RAW, an object is CAPTURED then validated —
|
|
291
|
+
// so the values the driver commands run are EXACTLY the values validation saw. A malicious accessor
|
|
292
|
+
// (`duration` 0.2 to validation, -1 after) cannot show validation one value and the command another; the
|
|
293
|
+
// getter is read exactly once. `undefined` is absence (rides the default). Returns the frozen snapshot.
|
|
294
|
+
function captureAndValidateTransition(
|
|
295
|
+
raw: Transition | undefined,
|
|
296
|
+
componentId: MotionComponentId,
|
|
297
|
+
propertyKeys: readonly string[] = ['__root__'],
|
|
298
|
+
): Transition | undefined {
|
|
299
|
+
if (raw === undefined) return undefined
|
|
300
|
+
// A CHEAP shape gate that reads NO option value (review majors 61/63): a primitive, null, array, or
|
|
301
|
+
// prototype-carrying object like `Date` retains the TYPED refusal — capturing it would launder an array
|
|
302
|
+
// / `Date` into an empty `{}`. Then capture the option values EXACTLY ONCE and validate the SNAPSHOT as
|
|
303
|
+
// the single truth: the getter is read once (the capture), the snapshot is what validation AND every
|
|
304
|
+
// command see, and the null-proto capture keeps an own `__proto__` as an own key so it reaches the
|
|
305
|
+
// snapshot validator. A second-read-throwing/stateful-but-legal object is materialized once, not reread.
|
|
306
|
+
const proto = typeof raw === 'object' && raw !== null ? Object.getPrototypeOf(raw) : undefined
|
|
307
|
+
if (
|
|
308
|
+
typeof raw !== 'object' ||
|
|
309
|
+
raw === null ||
|
|
310
|
+
Array.isArray(raw) ||
|
|
311
|
+
(proto !== Object.prototype && proto !== null)
|
|
312
|
+
) {
|
|
313
|
+
throw new InvalidTransitionError(
|
|
314
|
+
componentId,
|
|
315
|
+
'(transition)',
|
|
316
|
+
raw,
|
|
317
|
+
'a transition must be a plain object of transition options — a primitive, array, or ' +
|
|
318
|
+
'prototype-carrying object is refused (REQ-API-013; a malformed config never coerces to default)',
|
|
319
|
+
)
|
|
320
|
+
}
|
|
321
|
+
const snapshot = captureTransition(raw)
|
|
322
|
+
validatePropertyLaneTransition(snapshot, componentId, propertyKeys)
|
|
323
|
+
return snapshot
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// L1 (specs/L1-BUILD-PACKET.md): rgba-typed keys animate by scalar progress through the numeric
|
|
327
|
+
// driver seam (REQ-DRIVER-014 unchanged); everything else stays numeric fast-tier. The registry is
|
|
328
|
+
// the single classification source — the same table the web engine's disposition rides.
|
|
329
|
+
function isColorKey(key: string): boolean {
|
|
330
|
+
return nativeMountTargetValueKind(key) === 'color'
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// T23 B2b: discrete-typed keys (display/visibility) ride the color lane's [0,100] progress
|
|
334
|
+
// convention with a projected KEYWORD (a step or a constant, per REQ-VALUETYPE-015's two paths)
|
|
335
|
+
// — same single classification source.
|
|
336
|
+
function isDiscreteKey(key: string): boolean {
|
|
337
|
+
return nativeMountTargetValueKind(key) === 'discrete'
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Positional length keys that Motion measure-resolves (DOMKeyframesResolver positionalValues).
|
|
341
|
+
// The ONE authority is shippedSurface's NATIVE_MEASURE_LENGTH_KEYS — the variants gate reads the
|
|
342
|
+
// same set, so it never admits a length string this lane cannot execute (review b3c3dd6b431a).
|
|
343
|
+
function isPositionalLengthKey(key: string): key is PositionalLengthAxis {
|
|
344
|
+
return isNativeMeasureLengthKey(key)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function isMeasureResolvedLengthString(value: unknown): value is string {
|
|
348
|
+
if (typeof value !== 'string') return false
|
|
349
|
+
// Total verdict predicate — never bare catch over parseLengthTarget (unexpected faults propagate
|
|
350
|
+
// from resolveLengthToPx / parseLengthTarget throw path elsewhere).
|
|
351
|
+
return isLengthTarget(value)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Trim-insensitive auto detection over a scalar or ANY keyframe element (review 356417a77aec):
|
|
355
|
+
// " AUTO " parses as auto, so the retarget re-measure and the unconstrained-paint detector must
|
|
356
|
+
// recognize it too — and a keyframe array re-measures when any element is auto.
|
|
357
|
+
function containsAutoLengthTarget(value: unknown): boolean {
|
|
358
|
+
if (typeof value === 'string') return isAuto(value)
|
|
359
|
+
return (
|
|
360
|
+
Array.isArray(value) && value.some((element) => typeof element === 'string' && isAuto(element))
|
|
361
|
+
)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Mount-refused keys drop out of a later update/sync target (review 39c6a3e7ee90): the mount's
|
|
365
|
+
// severity-channel report is the one notice; subsequent applications of the same refused
|
|
366
|
+
// property are refused through the same law rather than crashing the fixed-at-mount invariant.
|
|
367
|
+
function dropMountRefusedKeys(target: Target, mountRefusedKeys: ReadonlySet<string>): Target {
|
|
368
|
+
if (mountRefusedKeys.size === 0) return target
|
|
369
|
+
const kept: Record<string, unknown> = {}
|
|
370
|
+
for (const [key, value] of Object.entries(target as Record<string, unknown>)) {
|
|
371
|
+
if (!mountRefusedKeys.has(key)) kept[key] = value
|
|
372
|
+
}
|
|
373
|
+
return kept as Target
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// The FINAL resting form of a target (review 1578a42974bc): only a final AUTO keyframe rests at
|
|
377
|
+
// auto — an auto MIDDLE keyframe ([0,'auto',100]) is a measurement episode whose resting form is
|
|
378
|
+
// the numeric final value, and a % origin-only settle is a % measure episode. Auto-rest tracking
|
|
379
|
+
// (release + drift re-seed) reads this, never the broader "contains an auto" measure test.
|
|
380
|
+
function restsAtAutoForm(value: unknown): boolean {
|
|
381
|
+
if (typeof value === 'string') return isAuto(value)
|
|
382
|
+
if (Array.isArray(value) && value.length > 0) {
|
|
383
|
+
const last = value[value.length - 1]
|
|
384
|
+
return typeof last === 'string' && isAuto(last)
|
|
385
|
+
}
|
|
386
|
+
return false
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// A scalar RELATIVE measure form (review 7cec8ad963d8): %/calc/vw/vh — everything the measure
|
|
390
|
+
// lane resolves except auto (auto owns its own rest lane). Initial-only relative settles keep
|
|
391
|
+
// these forms so a later element/parent/viewport change re-measures them.
|
|
392
|
+
function isRelativeLengthForm(value: unknown): value is string {
|
|
393
|
+
return typeof value === 'string' && !isAuto(value) && isLengthTarget(value)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// The FINAL element of a target rests at a RELATIVE measure form (review 26cc511b0927): a
|
|
397
|
+
// scalar relative string, or a keyframe array whose last keyframe is one.
|
|
398
|
+
function restsAtRelativeForm(value: unknown): boolean {
|
|
399
|
+
if (typeof value === 'string') return isRelativeLengthForm(value)
|
|
400
|
+
if (Array.isArray(value) && value.length > 0) {
|
|
401
|
+
const last = value[value.length - 1]
|
|
402
|
+
return typeof last === 'string' && isRelativeLengthForm(last)
|
|
403
|
+
}
|
|
404
|
+
return false
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Element-wise identity of two keyframe arrays (the AUTHORED shape, not the resolved values) —
|
|
408
|
+
// a context-only re-resolution presents the same raw array (review 26cc511b0927).
|
|
409
|
+
function sameRawKeyframes(a: unknown, b: unknown): boolean {
|
|
410
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
|
|
411
|
+
for (let index = 0; index < a.length; index++) if (a[index] !== b[index]) return false
|
|
412
|
+
return true
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// L1 scope + R13 value-breadth (REQ-VALUETYPE-013): numeric OR measure-resolved length string.
|
|
416
|
+
// Measure-required forms resolve through layout context; bare unitless/px strings parse to numbers.
|
|
417
|
+
function numericValue(
|
|
418
|
+
key: string,
|
|
419
|
+
value: unknown,
|
|
420
|
+
layoutContext: LengthLayoutContext = {},
|
|
421
|
+
): number {
|
|
422
|
+
if (typeof value === 'number') {
|
|
423
|
+
if (!Number.isFinite(value)) {
|
|
424
|
+
throw new Error(`L1 scope: target '${key}' is non-finite ${value}`)
|
|
425
|
+
}
|
|
426
|
+
return value
|
|
427
|
+
}
|
|
428
|
+
if (
|
|
429
|
+
typeof value === 'string' &&
|
|
430
|
+
isPositionalLengthKey(key) &&
|
|
431
|
+
isMeasureResolvedLengthString(value)
|
|
432
|
+
) {
|
|
433
|
+
return resolveLengthToPx(value, key, layoutContext)
|
|
434
|
+
}
|
|
435
|
+
throw new Error(
|
|
436
|
+
`L1 scope: target '${key}' is ${JSON.stringify(value)}; only numeric values or measure-resolved ` +
|
|
437
|
+
'length strings (%/auto/simple calc/px) are driven for non-color keys (REQ-VALUETYPE-013).',
|
|
438
|
+
)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Cross-bundle safe: class identity can diverge between core source/dist and the consumer.
|
|
443
|
+
* Match on name + reason field rather than `instanceof`.
|
|
444
|
+
*/
|
|
445
|
+
function measureResolveContextReason(error: unknown): string | null {
|
|
446
|
+
if (!(error instanceof Error)) return null
|
|
447
|
+
if (error.name !== 'MeasureResolveContextError') return null
|
|
448
|
+
const reason = (error as { reason?: unknown }).reason
|
|
449
|
+
return typeof reason === 'string' ? reason : null
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Typed / shape refusals that own the severity boundary. Unexpected dependency faults
|
|
454
|
+
* (non-Error throws, non-boundary Errors) always propagate — never message-regex classification.
|
|
455
|
+
*/
|
|
456
|
+
function isMeasureResolveBoundaryError(error: unknown): error is Error {
|
|
457
|
+
if (measureResolveContextReason(error) !== null) return true
|
|
458
|
+
if (!(error instanceof Error)) return false
|
|
459
|
+
// L1 shape refusals are constructed only in this module with a fixed prefix.
|
|
460
|
+
return (
|
|
461
|
+
error.message.startsWith('L1 scope: target ') || error.message.startsWith('L1 scope: keyframe ')
|
|
462
|
+
)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Element-box measures (auto, x/y %) arrive from onLayout after first mount — defer, never drop key. */
|
|
466
|
+
function isDeferrableElementMeasureError(error: unknown): error is MeasureResolveContextError {
|
|
467
|
+
return measureResolveContextReason(error) === 'missing-element-measure'
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* The tri-state origin resolution (review 763d6bcb0194): a production-refused NONDEFERRABLE
|
|
472
|
+
* origin and an element-measure deferral must never share one null — the refused property is
|
|
473
|
+
* dropped (one report, fired exactly once HERE), a deferred origin waits for the element box.
|
|
474
|
+
*/
|
|
475
|
+
type OriginResolution =
|
|
476
|
+
| { readonly kind: 'resolved'; readonly value: number }
|
|
477
|
+
| { readonly kind: 'deferred' }
|
|
478
|
+
| { readonly kind: 'refused' }
|
|
479
|
+
|
|
480
|
+
function resolveNumericKeyVerdict(
|
|
481
|
+
key: string,
|
|
482
|
+
value: unknown,
|
|
483
|
+
layoutContext: LengthLayoutContext,
|
|
484
|
+
severity: 'development' | 'production',
|
|
485
|
+
report: (error: Error) => void,
|
|
486
|
+
): OriginResolution {
|
|
487
|
+
try {
|
|
488
|
+
return { kind: 'resolved', value: numericValue(key, value, layoutContext) }
|
|
489
|
+
} catch (error) {
|
|
490
|
+
if (isDeferrableElementMeasureError(error)) return { kind: 'deferred' }
|
|
491
|
+
if (!isMeasureResolveBoundaryError(error)) throw error
|
|
492
|
+
if (severity === 'development') throw error
|
|
493
|
+
report(error)
|
|
494
|
+
return { kind: 'refused' }
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Resolve one numeric target under severity (major a41d97e260cb): missing layout context for
|
|
500
|
+
* parent/%/viewport reports+drops in production; development throws. Element-measure misses
|
|
501
|
+
* return null with `defer` so the host can register the key and flush after onLayout.
|
|
502
|
+
*/
|
|
503
|
+
function resolveNumericKeyWithSeverity(
|
|
504
|
+
key: string,
|
|
505
|
+
value: unknown,
|
|
506
|
+
layoutContext: LengthLayoutContext,
|
|
507
|
+
severity: 'development' | 'production',
|
|
508
|
+
report: (error: Error) => void,
|
|
509
|
+
): number | null {
|
|
510
|
+
try {
|
|
511
|
+
return numericValue(key, value, layoutContext)
|
|
512
|
+
} catch (error) {
|
|
513
|
+
if (isDeferrableElementMeasureError(error)) {
|
|
514
|
+
// First-mount path: host registers the key and flushes after onLayout supplies auto sizes.
|
|
515
|
+
return null
|
|
516
|
+
}
|
|
517
|
+
if (!isMeasureResolveBoundaryError(error)) throw error
|
|
518
|
+
if (severity === 'development') throw error
|
|
519
|
+
report(error)
|
|
520
|
+
return null
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// L1 typed entry gate (packet §semantics 6): a color endpoint must be a string core's parseColor
|
|
525
|
+
// accepts. Core validation already rejects wrong-primitive values (InvalidTargetError names the
|
|
526
|
+
// key — observed in the RED run); this gate wraps the PARSE failure with the key context.
|
|
527
|
+
function colorValue(key: string, value: unknown): RGBA {
|
|
528
|
+
if (typeof value !== 'string') {
|
|
529
|
+
throw new Error(
|
|
530
|
+
`L1: color target '${key}' is ${JSON.stringify(value)}; an rgba-typed key takes a color ` +
|
|
531
|
+
'string (specs/L1-BUILD-PACKET.md §semantics 6).',
|
|
532
|
+
)
|
|
533
|
+
}
|
|
534
|
+
// No parse catch (M3 r8 major b9eb82c0d429): every colorValue call site runs POST-gate,
|
|
535
|
+
// where core validation already proved the string parses — a throw here is a dependency
|
|
536
|
+
// fault or a core/L1 skew bug and must propagate loud, never be re-wrapped.
|
|
537
|
+
return parseColor(value)
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// The driver's internal progress convention for non-numeric values (REQ-SPRING-013): a color prop
|
|
541
|
+
// registers at PROGRESS_START and animates to PROGRESS_END; projection divides by PROGRESS_END.
|
|
542
|
+
const PROGRESS_START = 0
|
|
543
|
+
const PROGRESS_END = 100
|
|
544
|
+
|
|
545
|
+
// A RAW color TARGET value (R8 M2, review major 35): a scalar color string → its RGBA, OR a color
|
|
546
|
+
// keyframe ARRAY → its parsed RGBA sequence with a null-FIRST element kept as `null` (from-current,
|
|
547
|
+
// R8-F2 — substituted by the caller's live color in buildColorSequence). Core validation already
|
|
548
|
+
// proved a same-value-type array whose only legal null is index 0.
|
|
549
|
+
type ColorTarget = RGBA | readonly (RGBA | null)[]
|
|
550
|
+
function colorTargetValue(key: string, value: unknown): ColorTarget {
|
|
551
|
+
if (Array.isArray(value)) {
|
|
552
|
+
return value.map((element, index) => {
|
|
553
|
+
if (element === null) return null // legal ONLY at index 0 (validated upstream); seeded from live
|
|
554
|
+
if (typeof element !== 'string') {
|
|
555
|
+
throw new Error(
|
|
556
|
+
`L1: color keyframe '${key}[${index}]' is ${JSON.stringify(element)}; a color keyframe ` +
|
|
557
|
+
'is a color string (REQ-API-033 / specs/L1-BUILD-PACKET.md §semantics 6).',
|
|
558
|
+
)
|
|
559
|
+
}
|
|
560
|
+
return parseColor(element)
|
|
561
|
+
})
|
|
562
|
+
}
|
|
563
|
+
return colorValue(key, value)
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// The color TARGETS of a target (raw scalar RGBA or a keyframe RGBA sequence), each through the gate.
|
|
567
|
+
function resolveColors(target: Target): Record<string, ColorTarget> {
|
|
568
|
+
const resolved: Record<string, ColorTarget> = {}
|
|
569
|
+
for (const key of Object.keys(target)) {
|
|
570
|
+
if (!isColorKey(key)) continue
|
|
571
|
+
resolved[key] = colorTargetValue(key, (target as Record<string, unknown>)[key])
|
|
572
|
+
}
|
|
573
|
+
return resolved
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function isColorArrayTarget(target: ColorTarget): target is readonly (RGBA | null)[] {
|
|
577
|
+
return Array.isArray(target)
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// The typed entry gate for a discrete target (T23 B2b): a SINGLE keyword string of the discrete
|
|
581
|
+
// FAMILY. isDiscreteKeyword pre-guards the parse (a letters-only string can never throw in
|
|
582
|
+
// parseValue), and the family verdict catches keywords other families own ('auto', named colors).
|
|
583
|
+
// Keyframe ARRAYS are not chartered for discrete keys — refuse loud, never a silent scalar pick.
|
|
584
|
+
function discreteValue(key: string, value: unknown): string {
|
|
585
|
+
if (Array.isArray(value)) {
|
|
586
|
+
throw new Error(
|
|
587
|
+
`T23: discrete keyframe arrays are not implemented — '${key}' takes a single keyword ` +
|
|
588
|
+
'target (scalar display/visibility only).',
|
|
589
|
+
)
|
|
590
|
+
}
|
|
591
|
+
if (
|
|
592
|
+
typeof value !== 'string' ||
|
|
593
|
+
!isDiscreteKeyword(value) ||
|
|
594
|
+
parseValue(value).kind !== 'discrete'
|
|
595
|
+
) {
|
|
596
|
+
throw new Error(
|
|
597
|
+
`T23: discrete target '${key}' is ${JSON.stringify(value)}; a display/visibility target ` +
|
|
598
|
+
"is a single CSS keyword string (e.g. 'flex'/'none'/'visible'/'hidden').",
|
|
599
|
+
)
|
|
600
|
+
}
|
|
601
|
+
return value
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// The discrete TARGETS of a target, each through the gate (the resolveColors mirror).
|
|
605
|
+
function resolveDiscretes(target: Target): Record<string, string> {
|
|
606
|
+
const resolved: Record<string, string> = {}
|
|
607
|
+
for (const key of Object.keys(target)) {
|
|
608
|
+
if (!isDiscreteKey(key)) continue
|
|
609
|
+
resolved[key] = discreteValue(key, (target as Record<string, unknown>)[key])
|
|
610
|
+
}
|
|
611
|
+
return resolved
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Build the live RGBA SEQUENCE the driver's progress maps through (major 35): a SCALAR color target is
|
|
615
|
+
// the from-current 2-color `[fromColor, target]` (the sealed scalar-color lane); a color ARRAY is its
|
|
616
|
+
// parsed sequence with a null-FIRST element substituted by `fromColor` (R8-F2 from-current).
|
|
617
|
+
function buildColorSequence(target: ColorTarget, fromColor: RGBA): readonly RGBA[] {
|
|
618
|
+
if (!isColorArrayTarget(target)) return [fromColor, target]
|
|
619
|
+
return target.map((element) => element ?? fromColor)
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// The evenly-spaced numeric PROGRESS keyframe array for an N-color sequence (major 35): [0, …, 100]
|
|
623
|
+
// with N points, so the shipped keyframe generator owns offsets/per-segment easing/duration while the
|
|
624
|
+
// projector maps progress → the color segment. N ≥ 2 (a color target is ≥2 keyframes).
|
|
625
|
+
function colorProgressArray(n: number): readonly number[] {
|
|
626
|
+
return Array.from({ length: n }, (_, i) => (i / (n - 1)) * PROGRESS_END)
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// The SETTLED color of a color target — where the key rests once the animation completes (the LAST
|
|
630
|
+
// keyframe; a scalar rests at itself). Used to register a constant color (initial={false} / initial-
|
|
631
|
+
// only). The last element is non-null (validation guarantees a null only at index 0).
|
|
632
|
+
function settledColor(key: string, target: ColorTarget): RGBA {
|
|
633
|
+
if (!isColorArrayTarget(target)) return target
|
|
634
|
+
const last = target[target.length - 1]
|
|
635
|
+
if (last === null || last === undefined) {
|
|
636
|
+
throw new Error(`color keyframe array '${key}' has no final color (REQ-API-033).`)
|
|
637
|
+
}
|
|
638
|
+
return last
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// The canonical string of a WHOLE color target — the diff base for the M2.2 no-op invariant (review
|
|
642
|
+
// major 37). A scalar is its rgba() projection; an ARRAY is EVERY keyframe joined (a null-first element,
|
|
643
|
+
// from-current R8-F2, is its own marker), so two sequences that share endpoints but differ in a MIDDLE
|
|
644
|
+
// keyframe (red→green→blue vs red→yellow→blue) canonicalize DIFFERENTLY and correctly re-command — the
|
|
645
|
+
// old settled-color-only diff wrongly treated them as equal and continued the stale trajectory.
|
|
646
|
+
const FROM_CURRENT_MARKER = '~from-current~'
|
|
647
|
+
function colorTargetCanonical(target: ColorTarget): string {
|
|
648
|
+
if (!isColorArrayTarget(target)) return transformColor(target)
|
|
649
|
+
return target
|
|
650
|
+
.map((element) => (element === null ? FROM_CURRENT_MARKER : transformColor(element)))
|
|
651
|
+
.join('|')
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// A validated ANIMATE target value: a scalar number OR a numeric keyframe ARRAY (REQ-API-033, R8 M2).
|
|
655
|
+
// Core validation already proved a well-formed same-value-type keyframe array (length ≥2, null only at
|
|
656
|
+
// index 0); this gate rides it into the numeric lane and fails loud on any other shape (a unit string
|
|
657
|
+
// stays the flagged L1 stretch). Distinct from `numericValue` (start/from values, scalar-only).
|
|
658
|
+
function numericTargetValue(
|
|
659
|
+
key: string,
|
|
660
|
+
value: unknown,
|
|
661
|
+
layoutContext: LengthLayoutContext = {},
|
|
662
|
+
): number | PropTargetTo {
|
|
663
|
+
if (typeof value === 'number') return value
|
|
664
|
+
// Materialize the array ONCE into a frozen snapshot (review major 30): the driver + `current` diff
|
|
665
|
+
// base then hold the single truth, never aliasing caller memory a later mutation/accessor would skew.
|
|
666
|
+
if (Array.isArray(value)) {
|
|
667
|
+
const elements = value.map((element, index) => {
|
|
668
|
+
if (element === null && index === 0) return null
|
|
669
|
+
if (typeof element === 'number') return element
|
|
670
|
+
if (
|
|
671
|
+
typeof element === 'string' &&
|
|
672
|
+
isPositionalLengthKey(key) &&
|
|
673
|
+
isMeasureResolvedLengthString(element)
|
|
674
|
+
) {
|
|
675
|
+
return resolveLengthToPx(element, key, layoutContext)
|
|
676
|
+
}
|
|
677
|
+
throw new Error(
|
|
678
|
+
`L1 scope: keyframe '${key}[${index}]' is ${JSON.stringify(element)}; only numeric or ` +
|
|
679
|
+
'measure-resolved length values are driven (REQ-VALUETYPE-013).',
|
|
680
|
+
)
|
|
681
|
+
})
|
|
682
|
+
return Object.freeze(elements) as PropTargetTo
|
|
683
|
+
}
|
|
684
|
+
if (
|
|
685
|
+
typeof value === 'string' &&
|
|
686
|
+
isPositionalLengthKey(key) &&
|
|
687
|
+
isMeasureResolvedLengthString(value)
|
|
688
|
+
) {
|
|
689
|
+
return resolveLengthToPx(value, key, layoutContext)
|
|
690
|
+
}
|
|
691
|
+
throw new Error(
|
|
692
|
+
`L1 scope: target '${key}' is ${JSON.stringify(value)}; only a numeric value, measure-resolved ` +
|
|
693
|
+
'length string, or numeric keyframe array is driven for non-color keys (REQ-VALUETYPE-013).',
|
|
694
|
+
)
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// Resolve a validated ANIMATE target for the numeric lane: scalars pass through, a numeric keyframe
|
|
698
|
+
// array rides as its PropTargetTo; color keys are EXCLUDED (they ride the progress convention).
|
|
699
|
+
// Element-measure misses (auto / x|y %) go to `deferred` so mount can register the key and flush
|
|
700
|
+
// after onLayout — never drop from the fixed-at-mount key set.
|
|
701
|
+
function resolveAnimateTargets(
|
|
702
|
+
target: Target,
|
|
703
|
+
layoutContext: LengthLayoutContext = {},
|
|
704
|
+
severity: 'development' | 'production' = 'development',
|
|
705
|
+
report: (error: Error) => void = (): void => {},
|
|
706
|
+
): {
|
|
707
|
+
readonly resolved: Record<string, number | PropTargetTo>
|
|
708
|
+
readonly deferred: Record<string, unknown>
|
|
709
|
+
} {
|
|
710
|
+
const resolved: Record<string, number | PropTargetTo> = {}
|
|
711
|
+
const deferred: Record<string, unknown> = {}
|
|
712
|
+
for (const key of Object.keys(target)) {
|
|
713
|
+
if (isColorKey(key) || isDiscreteKey(key)) continue
|
|
714
|
+
const raw = (target as Record<string, unknown>)[key]
|
|
715
|
+
try {
|
|
716
|
+
resolved[key] = numericTargetValue(key, raw, layoutContext)
|
|
717
|
+
} catch (error) {
|
|
718
|
+
if (isDeferrableElementMeasureError(error)) {
|
|
719
|
+
deferred[key] = raw
|
|
720
|
+
continue
|
|
721
|
+
}
|
|
722
|
+
if (!isMeasureResolveBoundaryError(error)) throw error
|
|
723
|
+
if (severity === 'development') throw error
|
|
724
|
+
report(error)
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
return { resolved, deferred }
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/** Host-base numeric seed when a measure-required animate target is deferred to first onLayout. */
|
|
731
|
+
function deferredRegistrationSeed(
|
|
732
|
+
key: string,
|
|
733
|
+
style: MotionViewProps['style'],
|
|
734
|
+
layoutContext: LengthLayoutContext,
|
|
735
|
+
severity: 'development' | 'production',
|
|
736
|
+
report: (error: Error) => void,
|
|
737
|
+
): number {
|
|
738
|
+
const start = resolveStartValue(key, style !== undefined ? { style } : {})
|
|
739
|
+
if (typeof start === 'number' && Number.isFinite(start)) return start
|
|
740
|
+
if (
|
|
741
|
+
typeof start === 'string' &&
|
|
742
|
+
isPositionalLengthKey(key) &&
|
|
743
|
+
isMeasureResolvedLengthString(start)
|
|
744
|
+
) {
|
|
745
|
+
try {
|
|
746
|
+
return resolveLengthToPx(start, key, layoutContext)
|
|
747
|
+
} catch (error) {
|
|
748
|
+
if (isDeferrableElementMeasureError(error)) return 0
|
|
749
|
+
if (!isMeasureResolveBoundaryError(error)) throw error
|
|
750
|
+
if (severity === 'development') throw error
|
|
751
|
+
report(error)
|
|
752
|
+
return 0
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
return 0
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
type PendingDeferredLengthTarget = Readonly<{
|
|
759
|
+
/** Raw animate endpoint; resolved only after the element box exists. Absent on an
|
|
760
|
+
* initial-only pending origin (review 73044cc0e1bd) until an update pairs one. */
|
|
761
|
+
to?: unknown
|
|
762
|
+
/** Raw first-mount origin when it too requires the element box. */
|
|
763
|
+
from?: unknown
|
|
764
|
+
/** The deferring application's OWN transition (review 93aeec05a9a8): R7 law (b) submits
|
|
765
|
+
* label applications separately — ownership stays per entry, so a later application can
|
|
766
|
+
* never overwrite an earlier one's transition. An explicit null means the deferring
|
|
767
|
+
* application authored NO transition and owns the DEFAULT (review 7d395c90c954) —
|
|
768
|
+
* absence of a transition is an ownership state, never "retain the prior application's".
|
|
769
|
+
* Absent → the mount fallback. */
|
|
770
|
+
transition?: Transition | null
|
|
771
|
+
}>
|
|
772
|
+
|
|
773
|
+
type PendingDeferredLengthTargets = Record<string, PendingDeferredLengthTarget>
|
|
774
|
+
|
|
775
|
+
function deferredTargetsFrom(target: Record<string, unknown>): PendingDeferredLengthTargets {
|
|
776
|
+
const pending: PendingDeferredLengthTargets = {}
|
|
777
|
+
for (const [key, to] of Object.entries(target)) pending[key] = { to }
|
|
778
|
+
return pending
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// A transition object with at least one DEFINED option (review major 38): Motion treats an empty `{}`
|
|
782
|
+
// (and one whose every value is explicitly `undefined`) as NO transition — defaults apply — so it must
|
|
783
|
+
// not suppress the count-aware per-prop default. An explicitly-undefined option reads as absent (the R6
|
|
784
|
+
// option law), so it does not count as "defined".
|
|
785
|
+
function hasDefinedTransition(transition: Transition | undefined): boolean {
|
|
786
|
+
if (transition === undefined) return false
|
|
787
|
+
return Reflect.ownKeys(transition).some(
|
|
788
|
+
(key) => typeof key === 'string' && (transition as Record<string, unknown>)[key] !== undefined,
|
|
789
|
+
)
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// transition-default-selection F6: the pin's `isTransitionDefined` exempts EXACTLY these
|
|
793
|
+
// orchestration keys (motion-dom@12.42.2 utils/is-transition-defined.ts:4-29) — a flat bag whose
|
|
794
|
+
// every defined field is one of them is NOT a defined transition, so `animateMotionValue` merges
|
|
795
|
+
// the per-key default UNDER it (motion-value.ts:68-70 → utils/default-transitions.ts:35-48). The
|
|
796
|
+
// flat arm of perPropTransition is that pin seam and takes the pin's full list;
|
|
797
|
+
// TRANSITION_ORCHESTRATION_KEYS below stays the map branch's own (narrower) executable check.
|
|
798
|
+
const TRANSITION_DEFINED_EXEMPT_KEYS = new Set([
|
|
799
|
+
'when',
|
|
800
|
+
'delay',
|
|
801
|
+
'delayChildren',
|
|
802
|
+
'staggerChildren',
|
|
803
|
+
'staggerDirection',
|
|
804
|
+
'repeat',
|
|
805
|
+
'repeatType',
|
|
806
|
+
'repeatDelay',
|
|
807
|
+
'from',
|
|
808
|
+
'elapsed',
|
|
809
|
+
])
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Seed the T21 orchestration-computed delay as the per-key DEFAULT (REQ-API-053). Pin law
|
|
813
|
+
* (visual-element-target.ts:87-90): every value transition is `{ delay, ...getValueTransition }`
|
|
814
|
+
* — the computed delay applies wherever no delay is authored, and an authored delay at any
|
|
815
|
+
* altitude replaces it. Flat bags seed at the root; map bags seed each REPLACING property/
|
|
816
|
+
* `default` entry plus the root, so core resolveTransitionForKey's key → default → root
|
|
817
|
+
* precedence carries the default to unmapped keys; `inherit: true` entries are left alone
|
|
818
|
+
* (the merged root supplies the default at the correct precedence). The `layout` entry is
|
|
819
|
+
* NEVER seeded — orchestration is a property-lane concern and the layout builder owns its
|
|
820
|
+
* own delay consumption.
|
|
821
|
+
*/
|
|
822
|
+
// alloc-ok: lifecycle-edge — runs once per orchestrated update, never per frame.
|
|
823
|
+
function seedOrchestrationDelay(
|
|
824
|
+
transition: Transition | undefined,
|
|
825
|
+
delaySeconds: number,
|
|
826
|
+
): Transition {
|
|
827
|
+
if (!Number.isFinite(delaySeconds)) {
|
|
828
|
+
throw new Error(
|
|
829
|
+
`motionViewController: orchestrationDelaySeconds must be finite, got ${String(delaySeconds)} ` +
|
|
830
|
+
'— the upstream per-child resolver is broken (internal invariant, REQ-API-053)',
|
|
831
|
+
)
|
|
832
|
+
}
|
|
833
|
+
// The seed is written LAST so an own explicit `delay: undefined` (ABSENT under the R6 option
|
|
834
|
+
// law) cannot re-erase it via spread order (review r1 major 3).
|
|
835
|
+
const seedFlat = (bag: Transition): Transition =>
|
|
836
|
+
(bag as { delay?: number }).delay !== undefined
|
|
837
|
+
? bag
|
|
838
|
+
: (Object.freeze({ ...bag, delay: delaySeconds }) as Transition)
|
|
839
|
+
if (transition === undefined) return Object.freeze({ delay: delaySeconds }) as Transition
|
|
840
|
+
if (!hasTransitionMap(transition)) return seedFlat(transition)
|
|
841
|
+
const seeded: Record<string, unknown> = {}
|
|
842
|
+
for (const key of Reflect.ownKeys(transition)) {
|
|
843
|
+
if (typeof key !== 'string') continue
|
|
844
|
+
const value = (transition as Record<string, unknown>)[key]
|
|
845
|
+
// An `inherit: true` entry is NEVER pre-seeded (review r1 major 3): resolveTransitionForKey
|
|
846
|
+
// merges root-under-child for it, so the seeded ROOT already supplies the computed default
|
|
847
|
+
// at the lowest precedence — pre-seeding the child would sit the computed delay ABOVE the
|
|
848
|
+
// root's AUTHORED delay in that merge. Replacing entries (no inherit) drop the root, so
|
|
849
|
+
// they take the seed directly, reproducing the pin's `{ delay, ...getValueTransition }`.
|
|
850
|
+
seeded[key] =
|
|
851
|
+
key !== 'layout' &&
|
|
852
|
+
TRANSITION_MAP_KEYS.has(key) &&
|
|
853
|
+
typeof value === 'object' &&
|
|
854
|
+
value !== null &&
|
|
855
|
+
(value as { inherit?: boolean }).inherit !== true
|
|
856
|
+
? seedFlat(value as Transition)
|
|
857
|
+
: value
|
|
858
|
+
}
|
|
859
|
+
return seedFlat(seeded as Transition)
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const TRANSITION_ORCHESTRATION_KEYS = new Set(['delay', 'repeat', 'repeatType', 'repeatDelay'])
|
|
863
|
+
|
|
864
|
+
function hasExecutableTransition(transition: Transition | undefined): boolean {
|
|
865
|
+
if (transition === undefined) return false
|
|
866
|
+
for (const key of Reflect.ownKeys(transition)) {
|
|
867
|
+
if (
|
|
868
|
+
typeof key === 'string' &&
|
|
869
|
+
(transition as Record<string, unknown>)[key] !== undefined &&
|
|
870
|
+
!TRANSITION_ORCHESTRATION_KEYS.has(key)
|
|
871
|
+
)
|
|
872
|
+
return true
|
|
873
|
+
}
|
|
874
|
+
return false
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function definedTransitionFields(transition: Transition): Transition {
|
|
878
|
+
const defined: Record<string, unknown> = {}
|
|
879
|
+
for (const key of Reflect.ownKeys(transition)) {
|
|
880
|
+
if (typeof key !== 'string') continue
|
|
881
|
+
const value = (transition as Record<string, unknown>)[key]
|
|
882
|
+
if (value !== undefined) defined[key] = value
|
|
883
|
+
}
|
|
884
|
+
return defined as Transition
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function transitionForProperty(
|
|
888
|
+
transition: Transition | undefined,
|
|
889
|
+
key: string,
|
|
890
|
+
): Transition | undefined {
|
|
891
|
+
if (transition === undefined) return undefined
|
|
892
|
+
if (
|
|
893
|
+
!Reflect.ownKeys(transition).some(
|
|
894
|
+
(mapKey) => typeof mapKey === 'string' && TRANSITION_MAP_KEYS.has(mapKey),
|
|
895
|
+
)
|
|
896
|
+
)
|
|
897
|
+
return transition
|
|
898
|
+
return resolveTransitionForKey(transition, key)
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function effectiveTransitionForProperty(
|
|
902
|
+
key: string,
|
|
903
|
+
to: number | PropTargetTo,
|
|
904
|
+
transition: Transition | undefined,
|
|
905
|
+
): PropTransition | undefined {
|
|
906
|
+
const selected = transitionForProperty(transition, key)
|
|
907
|
+
if (selected === undefined || hasExecutableTransition(selected)) return selected
|
|
908
|
+
// Motion's `isTransitionDefined` ignores orchestration-only fields, then merges the property's
|
|
909
|
+
// count-aware default into the remaining delay/repeat options. This is the native-side equivalent
|
|
910
|
+
// for a map branch such as `x: { delay: 0.2 }` or `x: {}`; forwarding that sparse bag as a complete
|
|
911
|
+
// transition would make the driver choose its generic spring for every property.
|
|
912
|
+
const defaults = getDefaultTransition(key, Array.isArray(to) ? to : [null, to])
|
|
913
|
+
return { ...defaults, ...definedTransitionFields(selected) } as PropTransition
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function hasTransitionMap(transition: Transition): boolean {
|
|
917
|
+
return Reflect.ownKeys(transition).some(
|
|
918
|
+
(mapKey) => typeof mapKey === 'string' && TRANSITION_MAP_KEYS.has(mapKey),
|
|
919
|
+
)
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// The per-prop transition for an animate value (R8 M2, consult agent-2026-07-19-e715fd): ONLY a
|
|
923
|
+
// keyframe ARRAY with NO defined command-level transition carries a per-prop default — the count-aware
|
|
924
|
+
// getDefaultTransition (a 2-kf transform → spring, >2 → 800ms keyframes). A scalar, or an array under a
|
|
925
|
+
// DEFINED command-level transition, uses the command-level transition (returns undefined). An empty
|
|
926
|
+
// `{}` is NOT a defined transition (major 38). The drivers read `target.transition ?? command.transition`.
|
|
927
|
+
// REQ-API-035: reducedMotion skips transform (positional) keys; skipAnimations/isStatic skip all keys.
|
|
928
|
+
function perPropTransition(
|
|
929
|
+
key: string,
|
|
930
|
+
to: number | PropTargetTo,
|
|
931
|
+
commandTransition: Transition | undefined,
|
|
932
|
+
motionPolicy?: { readonly shouldReduceMotion?: boolean; readonly skipAnimations?: boolean },
|
|
933
|
+
): PropTransition | undefined {
|
|
934
|
+
if (motionPolicy?.skipAnimations === true) return INSTANT_TRANSITION
|
|
935
|
+
if (motionPolicy?.shouldReduceMotion === true && TRANSFORM_KEY_SET.has(key)) {
|
|
936
|
+
return INSTANT_TRANSITION
|
|
937
|
+
}
|
|
938
|
+
if (commandTransition !== undefined && hasTransitionMap(commandTransition)) {
|
|
939
|
+
return effectiveTransitionForProperty(key, to, commandTransition)
|
|
940
|
+
}
|
|
941
|
+
if (typeof to === 'number') {
|
|
942
|
+
// transition-default-selection F6 (REQ-SPRING-012, flat arm): the pin's `isTransitionDefined`
|
|
943
|
+
// exempts the orchestration keys (TRANSITION_DEFINED_EXEMPT_KEYS), so a flat bag whose every
|
|
944
|
+
// defined field is orchestration-only is NOT a defined transition — the per-key count-aware
|
|
945
|
+
// default merges UNDER the bag (the pin's `{ ...getDefaultTransition(...), ...valueTransition }`,
|
|
946
|
+
// motion-dom@12.42.2 motion-value.ts:68-70), the same shape effectiveTransitionForProperty
|
|
947
|
+
// already produces for a sparse MAP branch. An absent or fieldless (`{}`) bag keeps the
|
|
948
|
+
// command-level ride unchanged (the R8 M2 scalar law); a bag with ANY defined non-exempt field
|
|
949
|
+
// is defined and rides the command transition to the driver (an ease-only bag resolves there —
|
|
950
|
+
// the drivers' timing lane routes a defined typeless bag carrying an ease spelling).
|
|
951
|
+
if (commandTransition === undefined) return undefined
|
|
952
|
+
const defined = definedTransitionFields(commandTransition)
|
|
953
|
+
const definedKeys = Reflect.ownKeys(defined)
|
|
954
|
+
if (definedKeys.length === 0) return undefined
|
|
955
|
+
if (
|
|
956
|
+
definedKeys.some(
|
|
957
|
+
(definedKey) =>
|
|
958
|
+
typeof definedKey === 'string' && !TRANSITION_DEFINED_EXEMPT_KEYS.has(definedKey),
|
|
959
|
+
)
|
|
960
|
+
)
|
|
961
|
+
return undefined
|
|
962
|
+
return { ...getDefaultTransition(key, [null, to]), ...defined } as PropTransition
|
|
963
|
+
}
|
|
964
|
+
if (hasDefinedTransition(commandTransition)) return undefined
|
|
965
|
+
return getDefaultTransition(key, to)
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// Filter the R8-F1-incompatible keyframe arrays from an animate target BEFORE any resolve/register/seed/
|
|
969
|
+
// command (review majors 36 + 42): a keyframe array (numeric OR color — a color array's progress
|
|
970
|
+
// keyframes carry the same count) whose command-level transition is an EXPLICIT spring interpolates
|
|
971
|
+
// exactly two keyframes; a longer array is refused HERE. Running up-front makes the whole mount/update
|
|
972
|
+
// ATOMIC (major 36: a rejection mutates nothing) AND routes INDIRECT consumers through the severity
|
|
973
|
+
// boundary (major 42: a gesture/label array riding the element-transition fallback reaches the
|
|
974
|
+
// controller past its supplying gate) — production reports + drops the property (siblings survive),
|
|
975
|
+
// development throws (the direct-consumer loud law). The per-prop default is never a spring for a
|
|
976
|
+
// refusable count, so the command transition is the sole trigger.
|
|
977
|
+
function filterStartCompat(
|
|
978
|
+
animate: Target,
|
|
979
|
+
commandTransition: Transition | undefined,
|
|
980
|
+
severity: NativeSeverity,
|
|
981
|
+
report: (error: Error) => void,
|
|
982
|
+
componentId: MotionComponentId,
|
|
983
|
+
): Target {
|
|
984
|
+
if (!hasDefinedTransition(commandTransition)) return animate
|
|
985
|
+
const kept: Record<string, unknown> = {}
|
|
986
|
+
for (const [key, value] of Object.entries(animate)) {
|
|
987
|
+
const effectiveTransition = transitionForProperty(commandTransition, key)
|
|
988
|
+
let refusal: Error | null = null
|
|
989
|
+
if (effectiveTransition?.type === 'spring' && Array.isArray(value)) {
|
|
990
|
+
const springRefusal = springKeyframeCountRefusal(value.length)
|
|
991
|
+
if (springRefusal !== null) refusal = springRefusal
|
|
992
|
+
}
|
|
993
|
+
if (refusal === null)
|
|
994
|
+
refusal = keyframeTimingRefusal(componentId, key, value, effectiveTransition)
|
|
995
|
+
if (refusal !== null) {
|
|
996
|
+
if (severity === 'development') throw refusal
|
|
997
|
+
report(refusal)
|
|
998
|
+
continue // production: refuse the property, valid siblings survive
|
|
999
|
+
}
|
|
1000
|
+
kept[key] = value
|
|
1001
|
+
}
|
|
1002
|
+
return kept as Target
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// Value equality for the update diff (M2.2 no-op invariant): scalars by ===, keyframe arrays by
|
|
1006
|
+
// element-wise equality so a new-but-EQUAL array on an unrelated re-render issues NO command.
|
|
1007
|
+
function sameTargetValue(a: number | PropTargetTo, b: number | PropTargetTo): boolean {
|
|
1008
|
+
if (typeof a === 'number' || typeof b === 'number') return a === b
|
|
1009
|
+
if (a.length !== b.length) return false
|
|
1010
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false
|
|
1011
|
+
return true
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// The SETTLED value of an animate target — where the element rests once the animation completes. A
|
|
1015
|
+
// scalar rests at itself; a keyframe array rests at its LAST keyframe (validation guarantees a non-null
|
|
1016
|
+
// numeric final element). Used for the `initial={false}` sentinel (mount AT the settled state).
|
|
1017
|
+
function settledTargetValue(key: string, value: number | PropTargetTo): number {
|
|
1018
|
+
if (typeof value === 'number') return value
|
|
1019
|
+
const last = value[value.length - 1]
|
|
1020
|
+
if (typeof last !== 'number') {
|
|
1021
|
+
throw new Error(`keyframe array target '${key}' has no numeric final value (REQ-API-033).`)
|
|
1022
|
+
}
|
|
1023
|
+
return last
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
export type NativeSeverity = 'development' | 'production'
|
|
1027
|
+
|
|
1028
|
+
// The omitted-animate mount target: one frozen instance so every static MotionView shares it and
|
|
1029
|
+
// the controller's resolved-value diffing sees a stable identity.
|
|
1030
|
+
export const EMPTY_TARGET: Target = Object.freeze({})
|
|
1031
|
+
|
|
1032
|
+
function animatePropRefusal(error: unknown, componentId: MotionComponentId): Error {
|
|
1033
|
+
// Caught values are untrusted, including Error instances with hostile prototype or message
|
|
1034
|
+
// accessors. Re-wrapping through the total formatter keeps the severity boundary owned.
|
|
1035
|
+
return new InvalidTargetError(
|
|
1036
|
+
componentId,
|
|
1037
|
+
'(target)',
|
|
1038
|
+
describeValue(error),
|
|
1039
|
+
'malformed animate target (REQ-API-013)',
|
|
1040
|
+
)
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// The component's animate-prop boundary (round 9 major 31 + U7h): omitted (undefined) animate
|
|
1044
|
+
// AND a boolean animate are the empty static target. Boolean is the pin's parse-skip
|
|
1045
|
+
// (`typeof prop === "boolean"`) — accepted and inert, not a disable switch. `??` silently
|
|
1046
|
+
// converted an explicit `animate={null}` into a static mount that the web entry rejects as a
|
|
1047
|
+
// typed failure; null stays loud. Runs in the RENDER body on every render, so a malformed
|
|
1048
|
+
// shape arriving on an UPDATE fails here too, before it can reach Object.keys inside the
|
|
1049
|
+
// update planner (raw TypeError) or any driver mutation. Development throws; production
|
|
1050
|
+
// reports the typed error and refuses the whole target (static view).
|
|
1051
|
+
export function resolveAnimateProp(
|
|
1052
|
+
animate: Target | boolean | undefined,
|
|
1053
|
+
severity: NativeSeverity,
|
|
1054
|
+
report: (error: Error) => void,
|
|
1055
|
+
componentId: MotionComponentId,
|
|
1056
|
+
): Target {
|
|
1057
|
+
// U7h (REQ-API-057): pin animation-state.ts skips `typeof prop === "boolean"` — accepted
|
|
1058
|
+
// and inert. Not the initial={false} sentinel and not a disable switch. null stays loud.
|
|
1059
|
+
if (animate === undefined || typeof animate === 'boolean') return EMPTY_TARGET
|
|
1060
|
+
try {
|
|
1061
|
+
assertTargetShape(animate, { componentId })
|
|
1062
|
+
} catch (error) {
|
|
1063
|
+
const refusal = animatePropRefusal(error, componentId)
|
|
1064
|
+
if (severity === 'development') throw refusal
|
|
1065
|
+
report(refusal)
|
|
1066
|
+
return EMPTY_TARGET
|
|
1067
|
+
}
|
|
1068
|
+
return animate
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// Per-key severity validation — the web shim's validateWithSeverity mirrored natively (review
|
|
1072
|
+
// round 6; the ratified severity law is ONE policy across engines): development throws through
|
|
1073
|
+
// core's validator untouched; production validates PER KEY, reports each offender through the
|
|
1074
|
+
// error channel, and refuses it — the remaining keys proceed.
|
|
1075
|
+
// One key's FULL boundary check: core validation plus the L1 value-shape gate (review round 7
|
|
1076
|
+
// major a6139e22 — `x: '10px'` passes core validation but is a rung-level value failure; the
|
|
1077
|
+
// boundary must catch it BEFORE any driver/controller mutation, on both severity lanes).
|
|
1078
|
+
function keyBoundaryRefusal(
|
|
1079
|
+
key: string,
|
|
1080
|
+
value: unknown,
|
|
1081
|
+
allowArrays: boolean,
|
|
1082
|
+
commandTransition: Transition | undefined,
|
|
1083
|
+
componentId: MotionComponentId,
|
|
1084
|
+
): Error | null {
|
|
1085
|
+
const effectiveTransition = transitionForProperty(commandTransition, key)
|
|
1086
|
+
// RETURN-shaped (M3 r8 major b9eb82c0d429): the returned refusal is boundary/validator-
|
|
1087
|
+
// made by construction; anything THROWN through core validation (a polluted parse
|
|
1088
|
+
// dependency) propagates untouched — no catch exists on this path.
|
|
1089
|
+
// A keyframe ARRAY is only an ANIMATE target (REQ-API-033); `initial`/`from` are a SINGLE start
|
|
1090
|
+
// value, so an array there is refused AT the boundary (review major 34) — never left to escape as
|
|
1091
|
+
// an uncaught resolveNumeric throw downstream.
|
|
1092
|
+
if (!allowArrays && Array.isArray(value)) {
|
|
1093
|
+
return new Error(
|
|
1094
|
+
`'${key}' takes a single start value, not a keyframe array — a keyframe array is an ANIMATE ` +
|
|
1095
|
+
'target only (REQ-API-010/011/033).',
|
|
1096
|
+
)
|
|
1097
|
+
}
|
|
1098
|
+
const coreRefusal = validateTargetRefusal({ [key]: value } as Target, NATIVE_HOST, {
|
|
1099
|
+
componentId,
|
|
1100
|
+
})
|
|
1101
|
+
if (coreRefusal !== null) return coreRefusal
|
|
1102
|
+
// R8-F1 through the SEVERITY boundary (review major 41): a keyframe array whose command-level
|
|
1103
|
+
// transition is an EXPLICIT spring interpolates exactly two keyframes — a longer array is refused
|
|
1104
|
+
// HERE (per-key: production reports + drops this key, valid siblings survive; development throws)
|
|
1105
|
+
// instead of escaping as an uncaught throw from the controller's atomicity preflight. An
|
|
1106
|
+
// omitted/empty or non-spring transition routes the array through the keyframes generator (no
|
|
1107
|
+
// refusal); the per-prop default is never a spring for a >2 count. Applies to numeric AND color
|
|
1108
|
+
// arrays (a color array's progress keyframes carry the same count).
|
|
1109
|
+
if (
|
|
1110
|
+
Array.isArray(value) &&
|
|
1111
|
+
hasDefinedTransition(effectiveTransition) &&
|
|
1112
|
+
effectiveTransition?.type === 'spring'
|
|
1113
|
+
) {
|
|
1114
|
+
const springRefusal = springKeyframeCountRefusal(value.length)
|
|
1115
|
+
if (springRefusal !== null) return springRefusal
|
|
1116
|
+
}
|
|
1117
|
+
// R8 M2-B (REQ-API-033 law d): validate keyframe timing before any family-specific early return.
|
|
1118
|
+
// Exact-count `times` override even offsets; other shape-valid counts fall back. Missing segment
|
|
1119
|
+
// easings are linear and extras are ignored. Scalar targets still refuse keyframe-only timing as
|
|
1120
|
+
// inert. Applies to numeric AND color arrays (color progress keyframes carry the same count).
|
|
1121
|
+
const timingRefusal = keyframeTimingRefusal(componentId, key, value, effectiveTransition)
|
|
1122
|
+
if (timingRefusal !== null) return timingRefusal
|
|
1123
|
+
if (key === 'transitionEnd') {
|
|
1124
|
+
// T23 B3: a settle-JUMP sub-target, not a driven value key. Core validateTarget deep-validated
|
|
1125
|
+
// its shape and every sub-value; the native boundary destructures it into the settle lane
|
|
1126
|
+
// (MotionView), and applyTransitionEnd re-gates each sub-value on its own family at jump time.
|
|
1127
|
+
return null
|
|
1128
|
+
}
|
|
1129
|
+
if (isColorKey(key)) {
|
|
1130
|
+
// A color keyframe ARRAY is core-validated above (same-value-type, length ≥2, null only at index 0
|
|
1131
|
+
// for from-current) and driven by the R8 M2 color lane (major 35, projectColorSequence); the
|
|
1132
|
+
// `initial`/`from` scalar lane already refused arrays at the top (allowArrays gate).
|
|
1133
|
+
if (Array.isArray(value)) return null
|
|
1134
|
+
if (typeof value !== 'string') {
|
|
1135
|
+
return new Error(
|
|
1136
|
+
`L1: color target '${key}' is ${JSON.stringify(value)}; an rgba-typed key takes a color ` +
|
|
1137
|
+
'string (specs/L1-BUILD-PACKET.md §semantics 6).',
|
|
1138
|
+
)
|
|
1139
|
+
}
|
|
1140
|
+
return null
|
|
1141
|
+
}
|
|
1142
|
+
if (isDiscreteKey(key)) {
|
|
1143
|
+
// T23 B2b: a single keyword string of the discrete family; arrays are not chartered. The
|
|
1144
|
+
// same verdict discreteValue throws — RETURN-shaped here so production reports + drops.
|
|
1145
|
+
if (Array.isArray(value)) {
|
|
1146
|
+
return new Error(
|
|
1147
|
+
`T23: discrete keyframe arrays are not implemented — '${key}' takes a single keyword ` +
|
|
1148
|
+
'target (scalar display/visibility only).',
|
|
1149
|
+
)
|
|
1150
|
+
}
|
|
1151
|
+
if (
|
|
1152
|
+
typeof value !== 'string' ||
|
|
1153
|
+
!isDiscreteKeyword(value) ||
|
|
1154
|
+
parseValue(value).kind !== 'discrete'
|
|
1155
|
+
) {
|
|
1156
|
+
return new Error(
|
|
1157
|
+
`T23: discrete target '${key}' is ${JSON.stringify(value)}; a display/visibility target ` +
|
|
1158
|
+
"is a single CSS keyword string (e.g. 'flex'/'none'/'visible'/'hidden').",
|
|
1159
|
+
)
|
|
1160
|
+
}
|
|
1161
|
+
return null
|
|
1162
|
+
}
|
|
1163
|
+
// A numeric keyframe ARRAY is core-validated above (REQ-API-033) and driven by the R8 M2 numeric
|
|
1164
|
+
// keyframe lane; a scalar number passes; measure-resolved length strings pass for positional keys
|
|
1165
|
+
// (REQ-VALUETYPE-013); anything else stays refused.
|
|
1166
|
+
if (typeof value === 'number' || Array.isArray(value)) return null
|
|
1167
|
+
if (
|
|
1168
|
+
typeof value === 'string' &&
|
|
1169
|
+
isPositionalLengthKey(key) &&
|
|
1170
|
+
isMeasureResolvedLengthString(value)
|
|
1171
|
+
) {
|
|
1172
|
+
return null
|
|
1173
|
+
}
|
|
1174
|
+
return new Error(
|
|
1175
|
+
`L1 scope: target '${key}' is ${JSON.stringify(value)}; only a numeric value, measure-resolved ` +
|
|
1176
|
+
'length string (%/auto/simple calc/px), or numeric keyframe array is driven for non-color keys ' +
|
|
1177
|
+
'(REQ-VALUETYPE-013).',
|
|
1178
|
+
)
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
function checkKeyAtBoundary(
|
|
1182
|
+
key: string,
|
|
1183
|
+
value: unknown,
|
|
1184
|
+
allowArrays: boolean,
|
|
1185
|
+
commandTransition: Transition | undefined,
|
|
1186
|
+
componentId: MotionComponentId,
|
|
1187
|
+
): void {
|
|
1188
|
+
const refusal = keyBoundaryRefusal(key, value, allowArrays, commandTransition, componentId)
|
|
1189
|
+
if (refusal !== null) throw refusal
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
export function gateTargetWithSeverity(
|
|
1193
|
+
target: Target,
|
|
1194
|
+
severity: NativeSeverity,
|
|
1195
|
+
report: (error: Error) => void,
|
|
1196
|
+
// Whether a keyframe ARRAY is a legal value here: the ANIMATE lane accepts arrays (REQ-API-033); the
|
|
1197
|
+
// `initial` lane is scalar-only (a single start value) and refuses them AT the boundary (major 34).
|
|
1198
|
+
allowArrays = true,
|
|
1199
|
+
// The command-level transition (major 41): its type drives the R8-F1 spring/keyframe-array
|
|
1200
|
+
// compatibility refusal per key, so an incompatible array is reported + dropped under severity here
|
|
1201
|
+
// instead of throwing uncaught from the controller. Absent for the `initial` lane (arrays refused).
|
|
1202
|
+
commandTransition: Transition | undefined = undefined,
|
|
1203
|
+
componentId: MotionComponentId = '<Motion.View>',
|
|
1204
|
+
): Target {
|
|
1205
|
+
return gateTargetWithSeverityResult(
|
|
1206
|
+
target,
|
|
1207
|
+
severity,
|
|
1208
|
+
report,
|
|
1209
|
+
allowArrays,
|
|
1210
|
+
commandTransition,
|
|
1211
|
+
componentId,
|
|
1212
|
+
).target
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
export interface GatedTargetWithSeverity {
|
|
1216
|
+
/** The immutable target snapshot that passed the per-property severity gate. */
|
|
1217
|
+
readonly target: Target
|
|
1218
|
+
/** Keys supplied to the gate before per-property refusals; derived from the same snapshot. */
|
|
1219
|
+
readonly attemptedKeys: readonly string[]
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
// The richer form lets a supplying boundary distinguish an absent fallback consumer from one whose
|
|
1223
|
+
// every property was already refused. It is deliberately derived from the same captured target as the
|
|
1224
|
+
// returned target: examining the caller's object again would recreate the validated/dispatched split.
|
|
1225
|
+
export function gateTargetWithSeverityResult(
|
|
1226
|
+
target: Target,
|
|
1227
|
+
severity: NativeSeverity,
|
|
1228
|
+
report: (error: Error) => void,
|
|
1229
|
+
allowArrays = true,
|
|
1230
|
+
commandTransition: Transition | undefined = undefined,
|
|
1231
|
+
componentId: MotionComponentId = '<Motion.View>',
|
|
1232
|
+
): GatedTargetWithSeverity {
|
|
1233
|
+
// Whole-target shape gate (round 9 major 31): a malformed non-object target (null, primitive,
|
|
1234
|
+
// array) is ONE typed failure and the WHOLE target is refused — there are no per-key survivors
|
|
1235
|
+
// of a shapeless target. Without this, Object.keys/entries silently coerced numbers and
|
|
1236
|
+
// booleans to the empty target and raw-TypeErrored on null, so native mounted a static view
|
|
1237
|
+
// for inputs the web entry rejects (REQ-API-013/014, one severity policy across engines).
|
|
1238
|
+
const shapeRefusal = targetShapeRefusal(target, { componentId })
|
|
1239
|
+
if (shapeRefusal !== null) {
|
|
1240
|
+
if (severity === 'development') throw shapeRefusal
|
|
1241
|
+
report(shapeRefusal)
|
|
1242
|
+
return { target: {} as Target, attemptedKeys: [] }
|
|
1243
|
+
}
|
|
1244
|
+
// Materialize each keyframe array member ONCE, before validation (review major 30 / web major 23):
|
|
1245
|
+
// an accessor-backed array read during validation and re-read on forward could substitute the
|
|
1246
|
+
// accepted value; the single frozen snapshot is the ONE truth validation AND forwarding (and the
|
|
1247
|
+
// controller's `current` diff base) see. A null-prototype accumulator keeps an own '__proto__' key
|
|
1248
|
+
// from vanishing to the legacy setter (major 24 parity); non-array members pass through.
|
|
1249
|
+
const captured = captureTargetArrays(target)
|
|
1250
|
+
if (captured.refusal !== null) {
|
|
1251
|
+
if (severity === 'development') throw captured.refusal
|
|
1252
|
+
report(captured.refusal)
|
|
1253
|
+
return { target: {} as Target, attemptedKeys: [] }
|
|
1254
|
+
}
|
|
1255
|
+
const snapshot = captured.target
|
|
1256
|
+
const attemptedKeys = Object.freeze(Object.keys(snapshot))
|
|
1257
|
+
if (severity === 'development') {
|
|
1258
|
+
validateAgainstNativeHost(snapshot, componentId)
|
|
1259
|
+
for (const [key, value] of Object.entries(snapshot)) {
|
|
1260
|
+
checkKeyAtBoundary(key, value, allowArrays, commandTransition, componentId)
|
|
1261
|
+
}
|
|
1262
|
+
return { target: snapshot, attemptedKeys }
|
|
1263
|
+
}
|
|
1264
|
+
// RETURN-shaped per key (M3 r8 major b9eb82c0d429): only the boundary's OWN refusals are
|
|
1265
|
+
// reported and dropped; a dependency fault thrown through core validation keeps its
|
|
1266
|
+
// exact identity — no catch exists here.
|
|
1267
|
+
const kept: Record<string, unknown> = Object.create(null)
|
|
1268
|
+
for (const [key, value] of Object.entries(snapshot)) {
|
|
1269
|
+
const refusal = keyBoundaryRefusal(key, value, allowArrays, commandTransition, componentId)
|
|
1270
|
+
if (refusal !== null) {
|
|
1271
|
+
report(refusal)
|
|
1272
|
+
continue
|
|
1273
|
+
}
|
|
1274
|
+
kept[key] = value
|
|
1275
|
+
}
|
|
1276
|
+
return { target: kept as Target, attemptedKeys }
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
// Materialize a validated-shape target's keyframe array members ONCE into frozen snapshots (review
|
|
1280
|
+
// major 30). A null-prototype accumulator is own-property-safe: an own enumerable '__proto__' key
|
|
1281
|
+
// lands as data instead of vanishing to the legacy prototype setter (major 24 parity), so it reaches
|
|
1282
|
+
// the capability gate. The shared bounded capture reads each caller array length and own index once;
|
|
1283
|
+
// scalars pass through.
|
|
1284
|
+
export function materializeTargetArrays(
|
|
1285
|
+
target: Target,
|
|
1286
|
+
componentId: MotionComponentId = '<Motion.View>',
|
|
1287
|
+
): Target {
|
|
1288
|
+
const captured = captureTargetArrays(target, componentId)
|
|
1289
|
+
if (captured.refusal !== null) throw captured.refusal
|
|
1290
|
+
return captured.target
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
function captureTargetArrays(
|
|
1294
|
+
target: Target,
|
|
1295
|
+
componentId: MotionComponentId = '<Motion.View>',
|
|
1296
|
+
): {
|
|
1297
|
+
readonly target: Target
|
|
1298
|
+
readonly refusal: InvalidTargetError | null
|
|
1299
|
+
} {
|
|
1300
|
+
const snapshot: Record<string, unknown> = Object.create(null)
|
|
1301
|
+
for (const key of Object.keys(target)) {
|
|
1302
|
+
const value = (target as Record<string, unknown>)[key]
|
|
1303
|
+
if (!Array.isArray(value)) {
|
|
1304
|
+
snapshot[key] = value
|
|
1305
|
+
continue
|
|
1306
|
+
}
|
|
1307
|
+
const capture = captureBoundedArray(value)
|
|
1308
|
+
if (capture.kind !== 'captured') {
|
|
1309
|
+
return {
|
|
1310
|
+
target: {} as Target,
|
|
1311
|
+
refusal: new InvalidTargetError(
|
|
1312
|
+
componentId,
|
|
1313
|
+
key,
|
|
1314
|
+
capturedArrayDescription(capture),
|
|
1315
|
+
'keyframe arrays must have a safe bounded length (REQ-API-033)',
|
|
1316
|
+
),
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
snapshot[key] = capture.values
|
|
1320
|
+
}
|
|
1321
|
+
return { target: snapshot as Target, refusal: null }
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
// VENDORED motion-prop predicate — framer-motion's `isValidMotionProp` (the pinned oracle's
|
|
1325
|
+
// own denial surface, review round 7 major f10c6c8a: a hand-maintained subset forwarded
|
|
1326
|
+
// Motion-valid capabilities like dragConstraints). Vendored rather than imported so the native
|
|
1327
|
+
// bundle never pulls motion/react; `motionPropOracle.canary.test.ts` compares this predicate
|
|
1328
|
+
// against the REAL isValidMotionProp and fails on drift. The declared public props
|
|
1329
|
+
// (initial/animate/exit/transition/style/onAnimationComplete/children) are destructured before
|
|
1330
|
+
// the gate and never reach it.
|
|
1331
|
+
export function isPinnedMotionProp(key: string): boolean {
|
|
1332
|
+
return isNativeViewMotionProp(key)
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
// The extra denial the native surface adds beyond motion's predicate: capabilities this repo
|
|
1336
|
+
// declares on the WEB ViewProps but has not pulled natively. EMPTY since FLAG 5a pulled
|
|
1337
|
+
// dragSnapPoints into the native surface; the mechanism stays for the next web-ahead capability.
|
|
1338
|
+
const NATIVE_DEFERRED_EXTRAS = new Set<string>()
|
|
1339
|
+
|
|
1340
|
+
/** Deferred drag/pan family names cited by residual Campaign 1 (REQ-API-024). */
|
|
1341
|
+
export function isDeferredDragFamilyProp(key: string): boolean {
|
|
1342
|
+
// R15 D5 (REQ-API-046): _dragX/_dragY SHIPPED — never deferred.
|
|
1343
|
+
// R15 D6 (REQ-API-047): onPan* SHIPPED — never deferred.
|
|
1344
|
+
if (key === 'dragOriginEvent') return true
|
|
1345
|
+
// R15 D4 (REQ-API-044): onDirectionLock + dragDirectionLock SHIPPED — never deferred.
|
|
1346
|
+
// R15 D5 (REQ-API-045): dragMomentum / dragTransition / dragSnapToOrigin SHIPPED — never deferred.
|
|
1347
|
+
// Shipped drag surface is destructured before this gate; remaining drag* rest props are deferred.
|
|
1348
|
+
// R15 D1 (REQ-API-041): dragPropagation SHIPPED (destructured + root lock) — never deferred.
|
|
1349
|
+
// R15 D3 (REQ-API-043): dragControls + dragListener SHIPPED — never deferred.
|
|
1350
|
+
if (
|
|
1351
|
+
key.startsWith('drag') &&
|
|
1352
|
+
key !== 'draggable' &&
|
|
1353
|
+
key !== 'dragPropagation' &&
|
|
1354
|
+
key !== 'dragControls' &&
|
|
1355
|
+
key !== 'dragListener' &&
|
|
1356
|
+
key !== 'dragDirectionLock' &&
|
|
1357
|
+
key !== 'dragMomentum' &&
|
|
1358
|
+
key !== 'dragTransition' &&
|
|
1359
|
+
key !== 'dragSnapToOrigin'
|
|
1360
|
+
)
|
|
1361
|
+
return true
|
|
1362
|
+
return false
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
export function gateDeferredViewProps(
|
|
1366
|
+
rest: Record<string, unknown>,
|
|
1367
|
+
severity: NativeSeverity,
|
|
1368
|
+
report: (error: Error) => void,
|
|
1369
|
+
componentId: MotionComponentId = '<Motion.View>',
|
|
1370
|
+
): Record<string, unknown> {
|
|
1371
|
+
const forwarded: Record<string, unknown> = {}
|
|
1372
|
+
for (const [key, value] of Object.entries(rest)) {
|
|
1373
|
+
if (value === undefined) continue
|
|
1374
|
+
if (isPinnedMotionProp(key) || NATIVE_DEFERRED_EXTRAS.has(key)) {
|
|
1375
|
+
const dragFamily = isDeferredDragFamilyProp(key)
|
|
1376
|
+
const error = new Error(
|
|
1377
|
+
dragFamily
|
|
1378
|
+
? `${componentId}: "${key}" is a deferred drag-family capability (REQ-API-024) — ` +
|
|
1379
|
+
'refused until its shipping campaign lands; never a silent free-drag substitute.'
|
|
1380
|
+
: `${componentId}: "${key}" is a motion capability outside the ratified native surface — it fails ` +
|
|
1381
|
+
'loudly on every engine until its own rung lands (REQ-API-006; BRIEF Decisions).',
|
|
1382
|
+
)
|
|
1383
|
+
if (severity === 'development') throw error
|
|
1384
|
+
report(error)
|
|
1385
|
+
continue
|
|
1386
|
+
}
|
|
1387
|
+
forwarded[key] = value
|
|
1388
|
+
}
|
|
1389
|
+
return forwarded
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
// The optional-animate growth rule (review round 5): a STATIC mount (no keys at all) accepting
|
|
1393
|
+
// its first keyed `animate` is valid Motion — the wrapper REMOUNTS its internals with the new
|
|
1394
|
+
// key set (new element registration; starts resolve from the current static visuals). Growing
|
|
1395
|
+
// a NON-empty mounted set stays the ratified L1 fixed-at-mount deferral: the update guard
|
|
1396
|
+
// throws loud, recorded as a Motion divergence until the dynamic-key rung.
|
|
1397
|
+
export type MotionViewUpdatePlan = 'update' | 'grow-from-empty'
|
|
1398
|
+
|
|
1399
|
+
export function planMotionViewUpdate(
|
|
1400
|
+
mountedKeys: readonly string[],
|
|
1401
|
+
animate: Target,
|
|
1402
|
+
): MotionViewUpdatePlan {
|
|
1403
|
+
if (mountedKeys.length === 0 && Object.keys(animate).length > 0) return 'grow-from-empty'
|
|
1404
|
+
return 'update'
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
export function createMotionViewController(
|
|
1408
|
+
binding: WorkletDriverBinding,
|
|
1409
|
+
props: MotionViewProps,
|
|
1410
|
+
hooks: MotionViewControllerHooks = {},
|
|
1411
|
+
): MotionViewController {
|
|
1412
|
+
let handle: ElementHandle | null = null
|
|
1413
|
+
let unmounted = false
|
|
1414
|
+
// The severity channel for the R8-F1 compat filter (major 42): a direct consumer defaults to the
|
|
1415
|
+
// development loud law; the component threads its ambient severity + reporter.
|
|
1416
|
+
const severity: NativeSeverity = hooks.severity ?? 'development'
|
|
1417
|
+
const report = hooks.report ?? ((): void => {})
|
|
1418
|
+
const componentId = props.componentId ?? '<Motion.View>'
|
|
1419
|
+
// A driver command can REFUSE from BELOW the boundary, and the boundary cannot pre-empt it: the
|
|
1420
|
+
// fold re-measures its iteration from the LIVE value, so a `repeat` that validation certified and
|
|
1421
|
+
// that `start` accepted can be refused at a retarget (T18-a round-7 review MAJOR). Left unhandled
|
|
1422
|
+
// that surfaced as an uncaught `InvalidTransitionError` from inside a React lifecycle — on BOTH
|
|
1423
|
+
// severity lanes, including production, whose entire purpose is that it reports instead.
|
|
1424
|
+
//
|
|
1425
|
+
// The ratified severity law owns this outcome and already says what it is (see `keyBoundaryRefusal`
|
|
1426
|
+
// above): development throws typed and untouched; production reports each offender through the
|
|
1427
|
+
// error channel and refuses it, while the REMAINING KEYS PROCEED. The last clause is why a refused
|
|
1428
|
+
// command is re-issued per key rather than dropped whole — the driver commits a retarget
|
|
1429
|
+
// atomically, so a multi-key refusal applied nothing at all and the survivors would otherwise be
|
|
1430
|
+
// punished for sharing a command with an offender.
|
|
1431
|
+
//
|
|
1432
|
+
// Only the fold's own refusal is routed here. Anything else is a different fault and propagates.
|
|
1433
|
+
// The recognition is STRUCTURAL and single-sourced in core: class identity cannot cross to the UI
|
|
1434
|
+
// runtime, so `instanceof` was decidable on the reference backend only and this router silently
|
|
1435
|
+
// stopped firing on the backend that ships (T18-a round-9 review BLOCKING 1).
|
|
1436
|
+
const isFoldRefusal = isRepeatFoldRefusal
|
|
1437
|
+
// A fold refusal is only reachable when the author asked for a fold — no resolved default injects
|
|
1438
|
+
// `repeat` — so this is the exact predicate for "this command could be refused from below".
|
|
1439
|
+
const transitionCarriesFold = (transition: unknown): boolean =>
|
|
1440
|
+
typeof transition === 'object' &&
|
|
1441
|
+
transition !== null &&
|
|
1442
|
+
(transition as { repeat?: unknown }).repeat !== undefined
|
|
1443
|
+
const commandCarriesFold = (command: Extract<DriverCommand, { targets: unknown }>): boolean => {
|
|
1444
|
+
if (transitionCarriesFold(command.transition)) return true
|
|
1445
|
+
if (command.kind === 'retarget' && command.targetTransitions !== undefined) {
|
|
1446
|
+
for (const key of Object.keys(command.targetTransitions)) {
|
|
1447
|
+
if (transitionCarriesFold(command.targetTransitions[key])) return true
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
if (command.kind !== 'start') return false
|
|
1451
|
+
// A start resolves its transition PER PROP (keyframe-count-aware defaults), so the fold can
|
|
1452
|
+
// live on a target rather than on the command.
|
|
1453
|
+
for (const key of Object.keys(command.targets)) {
|
|
1454
|
+
if (transitionCarriesFold(command.targets[key]!.transition)) return true
|
|
1455
|
+
}
|
|
1456
|
+
return false
|
|
1457
|
+
}
|
|
1458
|
+
// The identity-channel lane marker for the adapter binding (REQ-API-061): the declarative
|
|
1459
|
+
// update lane (MotionView's animateScope-carrying update) keeps a timed trajectory on the exact
|
|
1460
|
+
// identity, while an unmarked gesture-state/presence retarget of the same shape takes the
|
|
1461
|
+
// host-resolved carrier commit. Set per update()/mount() call; read once per issued command.
|
|
1462
|
+
let commandLaneIsDeclarative = false
|
|
1463
|
+
const issueCommand = (h: ElementHandle, command: DriverCommand): void => {
|
|
1464
|
+
const commands = rewritePathCommands(command, () => binding.driver.committed(h))
|
|
1465
|
+
try {
|
|
1466
|
+
for (const next of commands) {
|
|
1467
|
+
binding.driver.command(
|
|
1468
|
+
h,
|
|
1469
|
+
commandLaneIsDeclarative
|
|
1470
|
+
? (Object.assign(next, {
|
|
1471
|
+
[IDENTITY_DECLARATIVE_LANE_COMMAND]: true,
|
|
1472
|
+
}) as DriverCommand)
|
|
1473
|
+
: next,
|
|
1474
|
+
)
|
|
1475
|
+
}
|
|
1476
|
+
} catch (error) {
|
|
1477
|
+
if (!isFoldRefusal(error) || severity === 'development') throw error
|
|
1478
|
+
report(error)
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
// Only the target-bearing commands can carry a fold; `stop` has nothing to refuse.
|
|
1482
|
+
const commandThroughSeverity = (
|
|
1483
|
+
h: ElementHandle,
|
|
1484
|
+
command: Extract<DriverCommand, { targets: unknown }>,
|
|
1485
|
+
): void => {
|
|
1486
|
+
const keys = Object.keys(command.targets)
|
|
1487
|
+
// A raw DriverCommand has one command-level transition slot, so a map cannot represent distinct
|
|
1488
|
+
// scalar retarget timings there. Resolve it at this controller boundary: starts keep one atomic
|
|
1489
|
+
// command by placing the selected flat bag in each target slot; scalar retargets become one flat
|
|
1490
|
+
// command per selected bag. The concrete drivers and the shared prepared boundary therefore never
|
|
1491
|
+
// receive a map, while every key still follows the same resolver used by mount/layout validation.
|
|
1492
|
+
if (hasTransitionMap(command.transition)) {
|
|
1493
|
+
if (command.kind === 'retarget') {
|
|
1494
|
+
const selectedTransitions = keys.map((key) => ({
|
|
1495
|
+
key,
|
|
1496
|
+
selectedTransition: effectiveTransitionForProperty(
|
|
1497
|
+
key,
|
|
1498
|
+
command.targets[key]!,
|
|
1499
|
+
command.transition,
|
|
1500
|
+
),
|
|
1501
|
+
selectedAuthoredTransition: transitionForProperty(command.transition, key) ?? {},
|
|
1502
|
+
}))
|
|
1503
|
+
if (
|
|
1504
|
+
severity === 'development' &&
|
|
1505
|
+
selectedTransitions.some(({ selectedTransition }) =>
|
|
1506
|
+
transitionCarriesFold(selectedTransition),
|
|
1507
|
+
)
|
|
1508
|
+
) {
|
|
1509
|
+
// Keep every mapped property in one prepared command on the loud lane. The backend
|
|
1510
|
+
// preflight then sees the whole retarget before mutating any shared value; sequentially
|
|
1511
|
+
// issuing mapped keys would commit an early key before a later fold refusal (review major
|
|
1512
|
+
// n4wse8).
|
|
1513
|
+
const targetTransitions: Record<string, PropTransition> = {}
|
|
1514
|
+
for (const {
|
|
1515
|
+
key,
|
|
1516
|
+
selectedTransition,
|
|
1517
|
+
selectedAuthoredTransition,
|
|
1518
|
+
} of selectedTransitions) {
|
|
1519
|
+
targetTransitions[key] = selectedTransition ?? selectedAuthoredTransition
|
|
1520
|
+
}
|
|
1521
|
+
commandThroughSeverity(h, {
|
|
1522
|
+
...command,
|
|
1523
|
+
transition: {},
|
|
1524
|
+
targetTransitions,
|
|
1525
|
+
} as Extract<DriverCommand, { targets: unknown }>)
|
|
1526
|
+
return
|
|
1527
|
+
}
|
|
1528
|
+
for (const { key, selectedTransition, selectedAuthoredTransition } of selectedTransitions) {
|
|
1529
|
+
commandThroughSeverity(h, {
|
|
1530
|
+
...command,
|
|
1531
|
+
targets: { [key]: command.targets[key]! },
|
|
1532
|
+
transition: selectedAuthoredTransition,
|
|
1533
|
+
...(selectedTransition === undefined
|
|
1534
|
+
? {}
|
|
1535
|
+
: { targetTransitions: { [key]: selectedTransition } }),
|
|
1536
|
+
} as Extract<DriverCommand, { targets: unknown }>)
|
|
1537
|
+
}
|
|
1538
|
+
return
|
|
1539
|
+
}
|
|
1540
|
+
const targets: Record<string, PropTarget> = {}
|
|
1541
|
+
for (const key of keys) {
|
|
1542
|
+
const target = command.targets[key]!
|
|
1543
|
+
const targetTransition = target.transition
|
|
1544
|
+
const selectedTransition =
|
|
1545
|
+
targetTransition === undefined
|
|
1546
|
+
? effectiveTransitionForProperty(key, target.to, command.transition)
|
|
1547
|
+
: hasTransitionMap(targetTransition as Transition)
|
|
1548
|
+
? effectiveTransitionForProperty(key, target.to, targetTransition as Transition)
|
|
1549
|
+
: undefined
|
|
1550
|
+
targets[key] = {
|
|
1551
|
+
...target,
|
|
1552
|
+
...(targetTransition === undefined
|
|
1553
|
+
? selectedTransition === undefined
|
|
1554
|
+
? {}
|
|
1555
|
+
: { transition: selectedTransition }
|
|
1556
|
+
: hasTransitionMap(targetTransition as Transition)
|
|
1557
|
+
? selectedTransition === undefined
|
|
1558
|
+
? {}
|
|
1559
|
+
: { transition: selectedTransition }
|
|
1560
|
+
: {}),
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
commandThroughSeverity(h, {
|
|
1564
|
+
...command,
|
|
1565
|
+
targets,
|
|
1566
|
+
transition: {},
|
|
1567
|
+
} as Extract<DriverCommand, { targets: unknown }>)
|
|
1568
|
+
return
|
|
1569
|
+
}
|
|
1570
|
+
// Development keeps the command whole: the typed throw IS the law there, and nothing degrades.
|
|
1571
|
+
// Production splits a FOLD-bearing multi-key command into per-key commands BEFORE issuing it.
|
|
1572
|
+
// Splitting REACTIVELY — issue, catch, re-issue per key — cannot work on the backend that
|
|
1573
|
+
// ships: it crosses commands to the UI runtime through a fire-and-forget schedule, so a refusal
|
|
1574
|
+
// never returns to this caller as a throw and the healthy keys went down with the offender
|
|
1575
|
+
// (T18-a round-9 review BLOCKING 2). Splitting up front works on BOTH lanes because each key
|
|
1576
|
+
// becomes its own atomic command, and it costs nothing on the common path: a command with no
|
|
1577
|
+
// authored fold cannot be refused, so it keeps the single crossing (REQ-DRIVER-013).
|
|
1578
|
+
if (severity === 'development' || keys.length <= 1 || !commandCarriesFold(command)) {
|
|
1579
|
+
issueCommand(h, command)
|
|
1580
|
+
return
|
|
1581
|
+
}
|
|
1582
|
+
let fault: unknown = null
|
|
1583
|
+
for (const key of keys) {
|
|
1584
|
+
try {
|
|
1585
|
+
issueCommand(h, { ...command, targets: { [key]: command.targets[key]! } } as DriverCommand)
|
|
1586
|
+
} catch (error) {
|
|
1587
|
+
// A non-fold fault is a real defect and stays loud — but it is raised AFTER every other key
|
|
1588
|
+
// has had its command. Aborting the split mid-loop would leave it half-applied, which is
|
|
1589
|
+
// the shape the split exists to avoid. First fault wins (round-9 review MINOR).
|
|
1590
|
+
if (fault === null) fault = error
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
if (fault !== null) throw fault
|
|
1594
|
+
}
|
|
1595
|
+
const motionPolicyOf = (): {
|
|
1596
|
+
readonly shouldReduceMotion: boolean
|
|
1597
|
+
readonly skipAnimations: boolean
|
|
1598
|
+
} =>
|
|
1599
|
+
hooks.motionPolicyBox?.current ?? {
|
|
1600
|
+
shouldReduceMotion: false,
|
|
1601
|
+
skipAnimations: false,
|
|
1602
|
+
}
|
|
1603
|
+
const lengthLayoutOf = (): LengthLayoutContext => hooks.lengthLayoutContextBox?.current ?? {}
|
|
1604
|
+
// Diff bases for update() — tracking RESOLVED values (not prop identity) is what makes an
|
|
1605
|
+
// unrelated re-render a guaranteed no-op (M2.2 §semantics 3). Numeric keys diff on the resolved
|
|
1606
|
+
// number; color keys diff on the CANONICAL rgba() projection of the target (L1 §semantics),
|
|
1607
|
+
// so '#f00' vs 'rgb(255,0,0)' never issues a spurious command.
|
|
1608
|
+
let current: Record<string, number | PropTargetTo> = {}
|
|
1609
|
+
let activeRemovalValues: Readonly<Record<string, ResolvedValue>> | undefined
|
|
1610
|
+
const handedOffRemovalKeys = new Set<string>()
|
|
1611
|
+
// The color diff base (review major 37): the canonical of the WHOLE declared color target per key —
|
|
1612
|
+
// scalar rgba() or the full keyframe sequence — so a changed MIDDLE keyframe re-commands.
|
|
1613
|
+
const currentColorCanonical: Record<string, string> = {}
|
|
1614
|
+
// Live color SEQUENCE per rgba-typed key (L1 §semantics 3/4; R8 M2 color keyframe arrays, major 35):
|
|
1615
|
+
// the RGBA sequence the driver's progress maps through (a scalar color is a 2-element [from, to]).
|
|
1616
|
+
// Every mutation flows through seed() — state first, wrapper port second, driver command last — so
|
|
1617
|
+
// the animated style's endpoint shareds can never lag a fresh progress (no getter/mutator skew).
|
|
1618
|
+
const colors: Record<string, readonly RGBA[]> = {}
|
|
1619
|
+
// Live discrete PAIR + diff base per discrete-typed key (T23 B2b, the color-lane mirror): the
|
|
1620
|
+
// [from, to] keyword pair the driver's progress RESOLVES through — by whichever of
|
|
1621
|
+
// REQ-VALUETYPE-015's two paths the pair takes, the mixVisibility step or the zero-slot target
|
|
1622
|
+
// constant, which reads no progress at all. The diff base is the declared
|
|
1623
|
+
// target keyword (scalar-only lane). Every mutation flows through seedDiscrete — state first,
|
|
1624
|
+
// wrapper port second, driver command last (no getter/mutator skew).
|
|
1625
|
+
const discretes: Record<string, readonly [string, string]> = {}
|
|
1626
|
+
const currentDiscreteCanonical: Record<string, string> = {}
|
|
1627
|
+
// The fixed-at-mount key set (review major 52): the controller's OWN record of every registered key,
|
|
1628
|
+
// independent of the driver's `committed` map. An update proves EVERY changed key is a member BEFORE
|
|
1629
|
+
// any command — a scalar retarget to an unmounted key must not ride behind an already-committed array
|
|
1630
|
+
// start (atomicity), and membership can't rely on `committed` alone (a not-yet-stepped key is absent).
|
|
1631
|
+
const registeredKeys = new Set<string>()
|
|
1632
|
+
// Mount-refused keys (reviews 763d6bcb0194 + 39c6a3e7ee90): a key whose origin/seed refused
|
|
1633
|
+
// through the severity channel at mount is TERMINAL for this mount's life — it never
|
|
1634
|
+
// registers (fixed-at-mount key set), so any later update naming it must be dropped through
|
|
1635
|
+
// the same severity law instead of crashing on the fixed-key invariant. The mount report is
|
|
1636
|
+
// the one notice; subsequent applications of the same refused property drop silently.
|
|
1637
|
+
const mountRefusedKeys = new Set<string>()
|
|
1638
|
+
// Keys whose CURRENT resting target is an auto form (review ae0a42832d85): once the auto
|
|
1639
|
+
// animation settles, MotionView releases these to the intrinsic layout, and
|
|
1640
|
+
// remeasureAutoRests re-seeds their committed measure when content drifts — the next animate
|
|
1641
|
+
// edge then originates from the drifted content, never a stale number.
|
|
1642
|
+
const autoRestKeys = new Set<string>()
|
|
1643
|
+
// Keys whose CURRENT resting value came from an authored RELATIVE measure form and have NO
|
|
1644
|
+
// animate target owning their re-measure (initial-only %/calc/vw/vh rests, review
|
|
1645
|
+
// 7cec8ad963d8): the form is kept verbatim so remeasureAutoRests re-resolves it against a
|
|
1646
|
+
// changed element box / parent / viewport and INSTANT re-seeds.
|
|
1647
|
+
const relativeRestForms = new Map<string, string>()
|
|
1648
|
+
// The last AUTHORED array form each key commanded (review 26cc511b0927): an identical raw
|
|
1649
|
+
// array whose RESOLUTION changed means a context edge, never an authored replacement — the
|
|
1650
|
+
// array's resting final value retargets instead of replaying the sequence.
|
|
1651
|
+
const lastCommandedRawKeyframes = new Map<string, readonly unknown[]>()
|
|
1652
|
+
// Element-box endpoints (auto / x|y %) remain paired until onLayout. A first-mount `initial`
|
|
1653
|
+
// or authored style origin may require the same measurement as its animate endpoint; retaining
|
|
1654
|
+
// only `to` would manufacture a 0-origin instead of issuing the measured endpoint pair.
|
|
1655
|
+
let pendingDeferredAnimate: PendingDeferredLengthTargets | null = null
|
|
1656
|
+
let pendingDeferredTransition: Transition | undefined
|
|
1657
|
+
// Mount-deferred flush under initial={false} settles instantly (REQ-API-010). Post-mount
|
|
1658
|
+
// deferred flushes keep the command transition (review residual over-fix major).
|
|
1659
|
+
let mountDeferredNeedsInstantFlush = false
|
|
1660
|
+
function registerElement(registered: Record<string, number>): ElementHandle {
|
|
1661
|
+
for (const key of Object.keys(registered)) registeredKeys.add(key)
|
|
1662
|
+
return binding.driver.register(registered)
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
function requireHandle(): ElementHandle {
|
|
1666
|
+
if (handle === null) {
|
|
1667
|
+
throw new Error('MotionView controller used before mount(): mount the component first.')
|
|
1668
|
+
}
|
|
1669
|
+
return handle
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
// FLAG 5a staticKeys: register each not-yet-registered static key at its resolved start value
|
|
1673
|
+
// (REQ-API-011's rule — style wins, else the documented host base). Mutates the registration
|
|
1674
|
+
// map BEFORE driver.register so the element is born with the key; never adds a target. An
|
|
1675
|
+
// rgba-typed static key (r4 major 18: a gesture-only color) registers SETTLED at its start
|
|
1676
|
+
// color — from = to, progress at PROGRESS_END, the same constant-projection shape as an
|
|
1677
|
+
// initial-only color — so a later retarget re-seeds through the existing update color lane.
|
|
1678
|
+
function addStaticKeys(registered: Record<string, number>): void {
|
|
1679
|
+
for (const key of props.staticKeys ?? []) {
|
|
1680
|
+
if (key in registered) continue
|
|
1681
|
+
if (isDiscreteKey(key)) {
|
|
1682
|
+
// Settled at the start keyword (style-provided). A missing start is the severity-gated
|
|
1683
|
+
// discreteStartKeyword refusal (T23 B2a2): dev throws, production reports + skips the key.
|
|
1684
|
+
const startKeyword = discreteStartKeyword(key)
|
|
1685
|
+
if (startKeyword === null) continue
|
|
1686
|
+
seedDiscrete(key, startKeyword, [startKeyword, startKeyword])
|
|
1687
|
+
registered[key] = PROGRESS_END
|
|
1688
|
+
continue
|
|
1689
|
+
}
|
|
1690
|
+
const start = resolveStartValue(key, props.style !== undefined ? { style: props.style } : {})
|
|
1691
|
+
if (isColorKey(key)) {
|
|
1692
|
+
const startColor = colorValue(key, start)
|
|
1693
|
+
seed(key, startColor, [startColor, startColor]) // settled: from = to (constant projection)
|
|
1694
|
+
registered[key] = PROGRESS_END
|
|
1695
|
+
continue
|
|
1696
|
+
}
|
|
1697
|
+
const n = resolveNumericKeyWithSeverity(key, start, lengthLayoutOf(), severity, report)
|
|
1698
|
+
if (n !== null) registered[key] = n
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
// Seed a color key's live SEQUENCE (length ≥2; a scalar color is [from, to]) plus its DIFF BASE. The
|
|
1703
|
+
// diff base is the canonical of the DECLARED target (the whole sequence, major 37 — not just the
|
|
1704
|
+
// settled color); the wrapper port receives the live RGBA sequence so the animated-style worklet
|
|
1705
|
+
// projects through it (projectColorSequence). One mutation path — no getter/mutator skew.
|
|
1706
|
+
function seed(key: string, declaredTarget: ColorTarget, sequence: readonly RGBA[]): void {
|
|
1707
|
+
colors[key] = sequence
|
|
1708
|
+
currentColorCanonical[key] = colorTargetCanonical(declaredTarget)
|
|
1709
|
+
if (hooks.onColorEndpoints !== undefined) hooks.onColorEndpoints(key, sequence)
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
// T23 B2a2 (severity law): a discrete animate/static key with NO authored start — core's
|
|
1713
|
+
// TYPED host-base refusal (the default diverges across engines) — is an authoring error
|
|
1714
|
+
// owned by this boundary: development throws core's error; production reports it once and
|
|
1715
|
+
// the key is refused WHOLE (no seed, no registration, no target — the numeric refused-seed
|
|
1716
|
+
// posture). Non-discrete host-base refusals (unknown/complex keys) stay loud on every
|
|
1717
|
+
// severity.
|
|
1718
|
+
function discreteStartKeyword(key: string): string | null {
|
|
1719
|
+
try {
|
|
1720
|
+
return discreteValue(
|
|
1721
|
+
key,
|
|
1722
|
+
resolveStartValue(key, props.style !== undefined ? { style: props.style } : {}),
|
|
1723
|
+
)
|
|
1724
|
+
} catch (error) {
|
|
1725
|
+
if (!(error instanceof DiscreteHostBaseError)) throw error
|
|
1726
|
+
if (severity === 'development') throw error
|
|
1727
|
+
report(error)
|
|
1728
|
+
// TERMINAL for the key at this mount (the numeric refused-seed law): the update lane
|
|
1729
|
+
// skips it silently — one report, never a fixed-key throw on a later retarget.
|
|
1730
|
+
mountRefusedKeys.add(key)
|
|
1731
|
+
return null
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
// Seed a discrete key's live [from, to] pair plus its DIFF BASE (the declared target keyword) —
|
|
1736
|
+
// the seed() mirror; the wrapper port receives the pair so the animated-style worklet projects
|
|
1737
|
+
// through it (projectDiscreteStep). One mutation path — no getter/mutator skew.
|
|
1738
|
+
function seedDiscrete(
|
|
1739
|
+
key: string,
|
|
1740
|
+
declaredTarget: string,
|
|
1741
|
+
pair: readonly [string, string],
|
|
1742
|
+
): void {
|
|
1743
|
+
discretes[key] = pair
|
|
1744
|
+
currentDiscreteCanonical[key] = declaredTarget
|
|
1745
|
+
if (hooks.onDiscreteEndpoints !== undefined) hooks.onDiscreteEndpoints(key, pair)
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
return {
|
|
1749
|
+
mount() {
|
|
1750
|
+
commandLaneIsDeclarative = false
|
|
1751
|
+
// Fail-closed ordering (REQ-API-013): every target validates BEFORE the driver sees anything.
|
|
1752
|
+
// The transition validates through the same boundary (r7 major 41b089c8c0af) — the
|
|
1753
|
+
// component pre-gates with severity; a direct controller consumer gets the loud law,
|
|
1754
|
+
// including this lane's OWN vocabulary (r9 major 762e9d0a4c77: the property lane rebases
|
|
1755
|
+
// delay and never reads velocity — the component hands it the selected projection).
|
|
1756
|
+
// Capture + validate the transition ONCE (review major 61): a DIRECT controller consumer's raw
|
|
1757
|
+
// accessor-backed transition would otherwise be read by validation and RE-read by the filter,
|
|
1758
|
+
// per-prop defaults, and the command — the validated value must be the one the command runs.
|
|
1759
|
+
// Materialize the animate arrays ONCE, then validate the SNAPSHOT (review major 60 — the mount
|
|
1760
|
+
// analog of major 30): the shape gate is a top-level check that reads no array element, so a
|
|
1761
|
+
// frozen snapshot is the ONE truth validation, filtering, resolution, and forwarding all see; a
|
|
1762
|
+
// direct consumer's accessor-backed array cannot pass validation with one value and forward another.
|
|
1763
|
+
const animateShapeRefusal = targetShapeRefusal(props.animate, { componentId })
|
|
1764
|
+
if (animateShapeRefusal !== null) throw animateShapeRefusal
|
|
1765
|
+
const transition = captureAndValidateTransition(
|
|
1766
|
+
props.transition,
|
|
1767
|
+
componentId,
|
|
1768
|
+
Object.keys(props.animate),
|
|
1769
|
+
)
|
|
1770
|
+
const animateSnapshot = materializeTargetArrays(props.animate, componentId)
|
|
1771
|
+
validateAgainstNativeHost(animateSnapshot, componentId)
|
|
1772
|
+
if (props.initial !== false && props.initial !== undefined) {
|
|
1773
|
+
validateAgainstNativeHost(props.initial, componentId)
|
|
1774
|
+
}
|
|
1775
|
+
// Filter R8-F1-incompatible arrays under severity FIRST (major 42): an indirect consumer's array
|
|
1776
|
+
// riding an explicit-spring element transition is reported + dropped (production) or throws
|
|
1777
|
+
// (development) before any mutation, so the rest of the mount is atomic and the property is
|
|
1778
|
+
// refused rather than throwing uncaught downstream.
|
|
1779
|
+
const compatAnimate = filterStartCompat(
|
|
1780
|
+
animateSnapshot,
|
|
1781
|
+
transition,
|
|
1782
|
+
severity,
|
|
1783
|
+
report,
|
|
1784
|
+
componentId,
|
|
1785
|
+
)
|
|
1786
|
+
const layoutCtx = lengthLayoutOf()
|
|
1787
|
+
const { resolved: animate, deferred: deferredAnimate } = resolveAnimateTargets(
|
|
1788
|
+
compatAnimate,
|
|
1789
|
+
layoutCtx,
|
|
1790
|
+
severity,
|
|
1791
|
+
report,
|
|
1792
|
+
)
|
|
1793
|
+
// The mount's authored array forms seed the context-vs-authored discriminator (review
|
|
1794
|
+
// 26cc511b0927): a later context-only re-resolution of the SAME array is a responsive
|
|
1795
|
+
// retarget, not a sequence restart.
|
|
1796
|
+
for (const key of Object.keys(animate)) {
|
|
1797
|
+
const raw = (compatAnimate as Record<string, unknown>)[key]
|
|
1798
|
+
if (Array.isArray(raw)) lastCommandedRawKeyframes.set(key, raw)
|
|
1799
|
+
}
|
|
1800
|
+
const animateColors = resolveColors(compatAnimate)
|
|
1801
|
+
const animateDiscretes = resolveDiscretes(compatAnimate)
|
|
1802
|
+
if (Object.keys(deferredAnimate).length > 0) {
|
|
1803
|
+
pendingDeferredAnimate = deferredTargetsFrom(deferredAnimate)
|
|
1804
|
+
pendingDeferredTransition = transition
|
|
1805
|
+
mountDeferredNeedsInstantFlush = props.initial === false
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
if (props.initial === false) {
|
|
1809
|
+
// REQ-API-010 sentinel: first painted frame IS the settled animate state — register at the
|
|
1810
|
+
// animate values, no entrance command, element idle. A keyframe array rests at its LAST
|
|
1811
|
+
// keyframe (settledTargetValue). A color registers at PROGRESS_END with from = to: the
|
|
1812
|
+
// projection is constant at the target color.
|
|
1813
|
+
const registered: Record<string, number> = {}
|
|
1814
|
+
for (const key of Object.keys(animate)) {
|
|
1815
|
+
registered[key] = settledTargetValue(key, animate[key]!)
|
|
1816
|
+
}
|
|
1817
|
+
// Element-measure deferred keys still join the fixed-at-mount set at host base; flush
|
|
1818
|
+
// after onLayout retargets them to the measured settle without re-registering. A REFUSED
|
|
1819
|
+
// seed base (e.g. a missing-parent % style) never fabricates a zero registration —
|
|
1820
|
+
// one report at the verdict, key left unregistered (review 763d6bcb0194). The refusal is
|
|
1821
|
+
// TERMINAL for the key at this mount (review 39c6a3e7ee90): it leaves pending AND the
|
|
1822
|
+
// diff base too — a leftover pending would let the post-onLayout flush re-enter update()
|
|
1823
|
+
// and throw the fixed-key invariant OUTSIDE the severity channel.
|
|
1824
|
+
const refusedSeedKeys: string[] = []
|
|
1825
|
+
for (const key of Object.keys(deferredAnimate)) {
|
|
1826
|
+
if (!(key in registered)) {
|
|
1827
|
+
const seedVerdict = resolveNumericKeyVerdict(
|
|
1828
|
+
key,
|
|
1829
|
+
resolveStartValue(key, props.style !== undefined ? { style: props.style } : {}),
|
|
1830
|
+
layoutCtx,
|
|
1831
|
+
severity,
|
|
1832
|
+
report,
|
|
1833
|
+
)
|
|
1834
|
+
if (seedVerdict.kind === 'refused') {
|
|
1835
|
+
refusedSeedKeys.push(key)
|
|
1836
|
+
mountRefusedKeys.add(key)
|
|
1837
|
+
continue
|
|
1838
|
+
}
|
|
1839
|
+
registered[key] =
|
|
1840
|
+
seedVerdict.kind === 'resolved'
|
|
1841
|
+
? seedVerdict.value
|
|
1842
|
+
: deferredRegistrationSeed(key, props.style, layoutCtx, severity, report)
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
if (refusedSeedKeys.length > 0 && pendingDeferredAnimate !== null) {
|
|
1846
|
+
const retained: PendingDeferredLengthTargets = {}
|
|
1847
|
+
for (const key of Object.keys(pendingDeferredAnimate)) {
|
|
1848
|
+
if (!refusedSeedKeys.includes(key)) retained[key] = pendingDeferredAnimate[key]!
|
|
1849
|
+
}
|
|
1850
|
+
pendingDeferredAnimate = Object.keys(retained).length === 0 ? null : retained
|
|
1851
|
+
if (pendingDeferredAnimate === null) {
|
|
1852
|
+
pendingDeferredTransition = undefined
|
|
1853
|
+
mountDeferredNeedsInstantFlush = false
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
for (const key of Object.keys(animateColors)) {
|
|
1857
|
+
const target = animateColors[key]!
|
|
1858
|
+
const settled = settledColor(key, target)
|
|
1859
|
+
// initial=false: rest AT the settled color, constant projection (from = to). The DIFF BASE is
|
|
1860
|
+
// the DECLARED target (the whole array), not the settled scalar (review major 43) — else an
|
|
1861
|
+
// unchanged re-render diffs its full-sequence canonical against a settled-only base and wrongly
|
|
1862
|
+
// restarts, violating both the settled-mount rule and the no-command invariant.
|
|
1863
|
+
seed(key, target, [settled, settled])
|
|
1864
|
+
registered[key] = PROGRESS_END
|
|
1865
|
+
}
|
|
1866
|
+
// initial={false} discrete keys rest AT the settled target keyword (constant projection).
|
|
1867
|
+
for (const key of Object.keys(animateDiscretes)) {
|
|
1868
|
+
const keyword = animateDiscretes[key]!
|
|
1869
|
+
seedDiscrete(key, keyword, [keyword, keyword])
|
|
1870
|
+
registered[key] = PROGRESS_END
|
|
1871
|
+
}
|
|
1872
|
+
addStaticKeys(registered)
|
|
1873
|
+
seedPathRotationKey(registered, transition)
|
|
1874
|
+
handle = registerElement(registered)
|
|
1875
|
+
current = { ...animate }
|
|
1876
|
+
for (const key of Object.keys(deferredAnimate)) {
|
|
1877
|
+
// A refused seed never enters the diff base either (review 39c6a3e7ee90).
|
|
1878
|
+
if (!(key in current) && !refusedSeedKeys.includes(key)) current[key] = registered[key]!
|
|
1879
|
+
}
|
|
1880
|
+
return handle
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
// Start values per key (REQ-API-010/011): an `initial` target value wins, else the style
|
|
1884
|
+
// value, else the documented host base — the latter two via core's resolveStartValue. Color
|
|
1885
|
+
// start values resolve identically, then parse through the typed entry gate.
|
|
1886
|
+
// Start values per key (REQ-API-010/011): an `initial` target value wins, else the style
|
|
1887
|
+
// value, else the documented host base — the latter two via core's resolveStartValue. Color
|
|
1888
|
+
// start values resolve identically, then parse through the typed entry gate. Every origin
|
|
1889
|
+
// resolves through the tri-state verdict ONCE per mount (review 763d6bcb0194): a refusal
|
|
1890
|
+
// reports exactly once, and the same verdict is reused everywhere below — never re-reported.
|
|
1891
|
+
const initialVerdicts = new Map<string, OriginResolution>()
|
|
1892
|
+
const initial: Record<string, number> = {}
|
|
1893
|
+
// (initial === false returned above; here initial is a Target or undefined.)
|
|
1894
|
+
if (props.initial !== undefined) {
|
|
1895
|
+
for (const key of Object.keys(props.initial as Record<string, unknown>)) {
|
|
1896
|
+
if (isColorKey(key) || isDiscreteKey(key)) continue
|
|
1897
|
+
const verdict = resolveNumericKeyVerdict(
|
|
1898
|
+
key,
|
|
1899
|
+
(props.initial as Record<string, unknown>)[key],
|
|
1900
|
+
layoutCtx,
|
|
1901
|
+
severity,
|
|
1902
|
+
report,
|
|
1903
|
+
)
|
|
1904
|
+
initialVerdicts.set(key, verdict)
|
|
1905
|
+
if (verdict.kind === 'resolved') initial[key] = verdict.value
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
const initialColors = props.initial === undefined ? {} : resolveColors(props.initial)
|
|
1909
|
+
const initialDiscretes =
|
|
1910
|
+
props.initial === undefined ? {} : resolveDiscretes(props.initial as Target)
|
|
1911
|
+
const from: Record<string, number> = {}
|
|
1912
|
+
const mountDeferredPairs: PendingDeferredLengthTargets = {
|
|
1913
|
+
...pendingDeferredAnimate,
|
|
1914
|
+
}
|
|
1915
|
+
const rawStartValue = (key: string): unknown => {
|
|
1916
|
+
if (props.initial !== undefined && props.initial !== false && key in props.initial) {
|
|
1917
|
+
return (props.initial as Record<string, unknown>)[key]!
|
|
1918
|
+
}
|
|
1919
|
+
return resolveStartValue(key, props.style !== undefined ? { style: props.style } : {})
|
|
1920
|
+
}
|
|
1921
|
+
for (const key of Object.keys(animate)) {
|
|
1922
|
+
if (key in initial) {
|
|
1923
|
+
from[key] = initial[key]!
|
|
1924
|
+
} else {
|
|
1925
|
+
const rawStart = rawStartValue(key)
|
|
1926
|
+
const verdict =
|
|
1927
|
+
initialVerdicts.get(key) ??
|
|
1928
|
+
resolveNumericKeyVerdict(key, rawStart, layoutCtx, severity, report)
|
|
1929
|
+
if (verdict.kind === 'resolved') {
|
|
1930
|
+
from[key] = verdict.value
|
|
1931
|
+
} else if (verdict.kind === 'deferred') {
|
|
1932
|
+
// Do not start a resolved target from a synthetic 0 when initial/style needs the
|
|
1933
|
+
// element box. The deferred flush resolves and commands both endpoints together.
|
|
1934
|
+
mountDeferredPairs[key] = {
|
|
1935
|
+
to: (compatAnimate as Record<string, unknown>)[key]!,
|
|
1936
|
+
from: rawStart,
|
|
1937
|
+
}
|
|
1938
|
+
delete animate[key]
|
|
1939
|
+
} else {
|
|
1940
|
+
// Refused (review 763d6bcb0194): the report fired once, at the verdict — drop the
|
|
1941
|
+
// property entirely: never a fabricated-zero registration, never a pending measure.
|
|
1942
|
+
// Terminal for this mount (review 39c6a3e7ee90): later updates drop it silently.
|
|
1943
|
+
mountRefusedKeys.add(key)
|
|
1944
|
+
delete animate[key]
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
// Initial-ONLY unresolved origins (review 73044cc0e1bd): a first-mount initial key no
|
|
1949
|
+
// animate target names — the label-form topology initial="open" with
|
|
1950
|
+
// variants.open.height="auto" mounts with an EMPTY animate — must still join the fixed
|
|
1951
|
+
// mount key set (a temporary seed registration) with its raw origin held as a pending
|
|
1952
|
+
// endpoint, or a later label update meets an unmounted key. Origins already refused
|
|
1953
|
+
// through the severity channel by the initial verdict stay refused (never resurrected).
|
|
1954
|
+
// (initial === false returned above; here initial is a Target or undefined.)
|
|
1955
|
+
if (props.initial !== undefined) {
|
|
1956
|
+
for (const key of Object.keys(props.initial as Record<string, unknown>)) {
|
|
1957
|
+
if (isColorKey(key) || isDiscreteKey(key)) continue
|
|
1958
|
+
if (key in mountDeferredPairs) continue // animate loop already paired this origin
|
|
1959
|
+
if (key in initial) continue // resolved origin — the initial-only registration owns it
|
|
1960
|
+
const rawStart = (props.initial as Record<string, unknown>)[key]
|
|
1961
|
+
const verdict = initialVerdicts.get(key)!
|
|
1962
|
+
if (verdict.kind === 'deferred') {
|
|
1963
|
+
mountDeferredPairs[key] = { from: rawStart }
|
|
1964
|
+
}
|
|
1965
|
+
// Refused: the initial verdict already threw (development) or reported once
|
|
1966
|
+
// (production) — leave it refused, never registered.
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
// Deferred element-measure keys: register at the authored origin when available, otherwise a
|
|
1970
|
+
// temporary seed. A paired origin waits for onLayout with its target and never becomes 0→0.
|
|
1971
|
+
// A REFUSED origin (e.g. a missing-parent % style base) drops the pair entirely — one
|
|
1972
|
+
// report at the verdict, no fabricated-zero registration, no pending measure (review
|
|
1973
|
+
// 763d6bcb0194).
|
|
1974
|
+
for (const key of Object.keys(mountDeferredPairs)) {
|
|
1975
|
+
if (key in from) continue
|
|
1976
|
+
const pair = mountDeferredPairs[key]!
|
|
1977
|
+
if (pair.from === undefined && key in initial) {
|
|
1978
|
+
from[key] = initial[key]!
|
|
1979
|
+
continue
|
|
1980
|
+
}
|
|
1981
|
+
const rawStart = pair.from ?? rawStartValue(key)
|
|
1982
|
+
const verdict =
|
|
1983
|
+
initialVerdicts.get(key) ??
|
|
1984
|
+
resolveNumericKeyVerdict(key, rawStart, layoutCtx, severity, report)
|
|
1985
|
+
if (verdict.kind === 'resolved') {
|
|
1986
|
+
from[key] = verdict.value
|
|
1987
|
+
} else if (verdict.kind === 'deferred') {
|
|
1988
|
+
// A target deferred before this loop still needs its authored unresolved origin
|
|
1989
|
+
// retained. Pair it now instead of letting the later flush retarget from the 0 seed.
|
|
1990
|
+
mountDeferredPairs[key] = { ...pair, from: rawStart }
|
|
1991
|
+
from[key] = deferredRegistrationSeed(key, props.style, layoutCtx, severity, report)
|
|
1992
|
+
} else {
|
|
1993
|
+
mountRefusedKeys.add(key)
|
|
1994
|
+
delete mountDeferredPairs[key]
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
pendingDeferredAnimate =
|
|
1998
|
+
Object.keys(mountDeferredPairs).length === 0 ? null : mountDeferredPairs
|
|
1999
|
+
if (pendingDeferredAnimate !== null && pendingDeferredTransition === undefined) {
|
|
2000
|
+
// review 818eba29da3a: the animate endpoint may have RESOLVED while only the authored
|
|
2001
|
+
// initial/style origin deferred — the measured first animation still rides the
|
|
2002
|
+
// AUTHORED mount transition, never a defaulted {}.
|
|
2003
|
+
pendingDeferredTransition = transition
|
|
2004
|
+
}
|
|
2005
|
+
const registered: Record<string, number> = { ...from }
|
|
2006
|
+
const targets: Record<string, PropTarget> = {}
|
|
2007
|
+
for (const key of Object.keys(animate)) {
|
|
2008
|
+
const to = animate[key]!
|
|
2009
|
+
// An omitted-transition keyframe ARRAY carries its count-aware per-prop default; a scalar (or an
|
|
2010
|
+
// array under an explicit command-level transition) rides the command-level transition (R8 M2).
|
|
2011
|
+
const perProp = perPropTransition(key, to, transition, motionPolicyOf())
|
|
2012
|
+
targets[key] = {
|
|
2013
|
+
to,
|
|
2014
|
+
from: from[key]!,
|
|
2015
|
+
...(perProp !== undefined ? { transition: perProp } : {}),
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
// Initial-ONLY keys (review round 4 major 9 — the optional-animate surface): first paint
|
|
2019
|
+
// must honor `initial` exactly like the web entry, so the key registers at its initial
|
|
2020
|
+
// value with NO target — a static value until a later animate retargets it. An initial-only
|
|
2021
|
+
// RELATIVE form joins the relative-rest lane (review 7cec8ad963d8): no animate target owns
|
|
2022
|
+
// its re-measure, so a later context change re-resolves and re-seeds it here.
|
|
2023
|
+
for (const key of Object.keys(initial)) {
|
|
2024
|
+
if (!(key in targets)) {
|
|
2025
|
+
registered[key] = initial[key]!
|
|
2026
|
+
const rawInitial = (props.initial as Record<string, unknown>)[key]
|
|
2027
|
+
if (isRelativeLengthForm(rawInitial)) relativeRestForms.set(key, rawInitial)
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
// BUILD the color targets + collect the endpoint seeds WITHOUT mutating (major 36 atomicity): the
|
|
2031
|
+
// seeds apply only AFTER the whole target set pre-validates, so a rejected mount mutates nothing.
|
|
2032
|
+
const pendingSeeds: {
|
|
2033
|
+
key: string
|
|
2034
|
+
declaredTarget: ColorTarget
|
|
2035
|
+
sequence: readonly RGBA[]
|
|
2036
|
+
}[] = []
|
|
2037
|
+
for (const key of Object.keys(animateColors)) {
|
|
2038
|
+
const target = animateColors[key]!
|
|
2039
|
+
const fromColor: RGBA =
|
|
2040
|
+
key in initialColors
|
|
2041
|
+
? settledColor(key, initialColors[key]!) // initial is scalar-only ⇒ the start color
|
|
2042
|
+
: colorValue(
|
|
2043
|
+
key,
|
|
2044
|
+
resolveStartValue(key, props.style !== undefined ? { style: props.style } : {}),
|
|
2045
|
+
)
|
|
2046
|
+
pendingSeeds.push({
|
|
2047
|
+
key,
|
|
2048
|
+
declaredTarget: target,
|
|
2049
|
+
sequence: buildColorSequence(target, fromColor),
|
|
2050
|
+
})
|
|
2051
|
+
registered[key] = PROGRESS_START
|
|
2052
|
+
// A color ARRAY drives a numeric PROGRESS keyframe array [0…100] (per-prop count-aware default,
|
|
2053
|
+
// R8 M2 major 35); a scalar color rides the sealed 0→100 progress under the command transition.
|
|
2054
|
+
if (isColorArrayTarget(target)) {
|
|
2055
|
+
const progressTo = colorProgressArray(target.length)
|
|
2056
|
+
const perProp = perPropTransition(key, progressTo, transition, motionPolicyOf())
|
|
2057
|
+
targets[key] = {
|
|
2058
|
+
to: progressTo,
|
|
2059
|
+
from: PROGRESS_START,
|
|
2060
|
+
...(perProp !== undefined ? { transition: perProp } : {}),
|
|
2061
|
+
}
|
|
2062
|
+
} else {
|
|
2063
|
+
const perProp = perPropTransition(key, PROGRESS_END, transition, motionPolicyOf())
|
|
2064
|
+
targets[key] = {
|
|
2065
|
+
to: PROGRESS_END,
|
|
2066
|
+
from: PROGRESS_START,
|
|
2067
|
+
...(perProp !== undefined ? { transition: perProp } : {}),
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
// Initial-only COLOR keys register settled at the initial color (from = to: the
|
|
2072
|
+
// projection is constant), the same shape as the initial={false} sentinel.
|
|
2073
|
+
for (const key of Object.keys(initialColors)) {
|
|
2074
|
+
if (!(key in targets)) {
|
|
2075
|
+
const settled = settledColor(key, initialColors[key]!) // initial is scalar-only
|
|
2076
|
+
pendingSeeds.push({ key, declaredTarget: settled, sequence: [settled, settled] })
|
|
2077
|
+
registered[key] = PROGRESS_END
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
// BUILD the discrete targets + pair seeds WITHOUT mutating (the color block's major-36
|
|
2081
|
+
// atomicity): the start keyword is initial's, else style's, else the LOUD host-base refusal
|
|
2082
|
+
// (T23 B2a — the discrete default diverges across engines). There is no pair-legality probe:
|
|
2083
|
+
// since H3 every keyword pair is legal, and core's mixDiscrete answers all of them.
|
|
2084
|
+
const pendingDiscreteSeeds: {
|
|
2085
|
+
key: string
|
|
2086
|
+
declaredTarget: string
|
|
2087
|
+
pair: readonly [string, string]
|
|
2088
|
+
}[] = []
|
|
2089
|
+
for (const key of Object.keys(animateDiscretes)) {
|
|
2090
|
+
const target = animateDiscretes[key]!
|
|
2091
|
+
const fromKeyword: string | null =
|
|
2092
|
+
key in initialDiscretes ? initialDiscretes[key]! : discreteStartKeyword(key)
|
|
2093
|
+
if (fromKeyword === null) continue
|
|
2094
|
+
const pair: readonly [string, string] = [fromKeyword, target]
|
|
2095
|
+
pendingDiscreteSeeds.push({
|
|
2096
|
+
key,
|
|
2097
|
+
declaredTarget: target,
|
|
2098
|
+
pair,
|
|
2099
|
+
})
|
|
2100
|
+
registered[key] = PROGRESS_START
|
|
2101
|
+
// A scalar keyword rides the sealed 0→100 progress under the command transition (the
|
|
2102
|
+
// scalar-color shape; discrete keyframe arrays are refused upstream).
|
|
2103
|
+
const perProp = perPropTransition(key, PROGRESS_END, transition, motionPolicyOf())
|
|
2104
|
+
targets[key] = {
|
|
2105
|
+
to: PROGRESS_END,
|
|
2106
|
+
from: PROGRESS_START,
|
|
2107
|
+
...(perProp !== undefined ? { transition: perProp } : {}),
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
// Initial-only DISCRETE keys register settled at the initial keyword (constant projection).
|
|
2111
|
+
for (const key of Object.keys(initialDiscretes)) {
|
|
2112
|
+
if (!(key in targets)) {
|
|
2113
|
+
const keyword = initialDiscretes[key]!
|
|
2114
|
+
pendingDiscreteSeeds.push({ key, declaredTarget: keyword, pair: [keyword, keyword] })
|
|
2115
|
+
registered[key] = PROGRESS_END
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
// R8-F1-incompatible arrays were filtered up-front (compatAnimate) so `targets` holds none — the
|
|
2119
|
+
// build → commit order below leaks no registration/endpoint on a refusal (major 36 atomicity).
|
|
2120
|
+
for (const { key, declaredTarget, sequence } of pendingSeeds)
|
|
2121
|
+
seed(key, declaredTarget, sequence)
|
|
2122
|
+
for (const { key, declaredTarget, pair } of pendingDiscreteSeeds)
|
|
2123
|
+
seedDiscrete(key, declaredTarget, pair)
|
|
2124
|
+
addStaticKeys(registered)
|
|
2125
|
+
seedPathRotationKey(registered, transition)
|
|
2126
|
+
handle = registerElement(registered)
|
|
2127
|
+
// An initial-only mount has nothing to animate — no command, element idle at first paint.
|
|
2128
|
+
if (Object.keys(targets).length > 0) {
|
|
2129
|
+
commandThroughSeverity(handle, {
|
|
2130
|
+
kind: 'start',
|
|
2131
|
+
targets,
|
|
2132
|
+
transition: transition ?? {},
|
|
2133
|
+
})
|
|
2134
|
+
}
|
|
2135
|
+
current = { ...animate }
|
|
2136
|
+
for (const key of Object.keys(mountDeferredPairs)) {
|
|
2137
|
+
if (!(key in current)) current[key] = registered[key]!
|
|
2138
|
+
}
|
|
2139
|
+
return handle
|
|
2140
|
+
},
|
|
2141
|
+
|
|
2142
|
+
flushDeferredLengthMeasures(held) {
|
|
2143
|
+
if (pendingDeferredAnimate === null || handle === null) return false
|
|
2144
|
+
const pendingAll = pendingDeferredAnimate
|
|
2145
|
+
const retainHeld: PendingDeferredLengthTargets = {}
|
|
2146
|
+
// Flush commands are PARTITIONED by transition ownership (review 93aeec05a9a8): each
|
|
2147
|
+
// pending entry rides its deferring application's OWN transition (the mount fallback
|
|
2148
|
+
// covers unstamped mount pairs; the instant latch still wins while set). Keys sharing
|
|
2149
|
+
// one transition share one command.
|
|
2150
|
+
const flushTargetGroups = new Map<Transition | undefined, Record<string, unknown>>()
|
|
2151
|
+
const startGroups = new Map<
|
|
2152
|
+
Transition | undefined,
|
|
2153
|
+
{ targets: Record<string, PropTarget>; resolved: Record<string, number | PropTargetTo> }
|
|
2154
|
+
>()
|
|
2155
|
+
// Origin-only initial pendings that resolved this flush — settled INSTANT (review
|
|
2156
|
+
// 2bd345e800ff), never through the application transition.
|
|
2157
|
+
const settleTargets: Record<string, number> = {}
|
|
2158
|
+
const effectiveTransitionOf = (
|
|
2159
|
+
pending: PendingDeferredLengthTarget,
|
|
2160
|
+
): Transition | undefined =>
|
|
2161
|
+
mountDeferredNeedsInstantFlush
|
|
2162
|
+
? INSTANT_TRANSITION
|
|
2163
|
+
: pending.transition === null
|
|
2164
|
+
? undefined // explicit default ownership (review 7d395c90c954)
|
|
2165
|
+
: (pending.transition ?? pendingDeferredTransition)
|
|
2166
|
+
const layoutContext = lengthLayoutOf()
|
|
2167
|
+
for (const key of Object.keys(pendingAll)) {
|
|
2168
|
+
const pending = pendingAll[key]!
|
|
2169
|
+
if (held !== undefined && held.has(key)) {
|
|
2170
|
+
retainHeld[key] = pending
|
|
2171
|
+
continue
|
|
2172
|
+
}
|
|
2173
|
+
// An origin-only pending (initial-only key, review 73044cc0e1bd) holds while its origin
|
|
2174
|
+
// still needs the element box — and SETTLES the registration to the measured origin the
|
|
2175
|
+
// moment it resolves (review 2bd345e800ff): INSTANT, never an entrance animation — the
|
|
2176
|
+
// authored application transition is not an entrance for an initial-only key.
|
|
2177
|
+
if (pending.to === undefined) {
|
|
2178
|
+
try {
|
|
2179
|
+
const measured = numericValue(key, pending.from, layoutContext)
|
|
2180
|
+
settleTargets[key] = measured
|
|
2181
|
+
} catch (error) {
|
|
2182
|
+
if (isDeferrableElementMeasureError(error)) {
|
|
2183
|
+
retainHeld[key] = pending
|
|
2184
|
+
continue
|
|
2185
|
+
}
|
|
2186
|
+
if (!isMeasureResolveBoundaryError(error)) throw error
|
|
2187
|
+
if (severity === 'development') throw error
|
|
2188
|
+
report(error)
|
|
2189
|
+
// A refused origin at flush: dropped with the report, never retained as pending.
|
|
2190
|
+
}
|
|
2191
|
+
continue
|
|
2192
|
+
}
|
|
2193
|
+
const transition = effectiveTransitionOf(pending)
|
|
2194
|
+
if (pending.from === undefined) {
|
|
2195
|
+
const group = flushTargetGroups.get(transition) ?? {}
|
|
2196
|
+
group[key] = pending.to
|
|
2197
|
+
flushTargetGroups.set(transition, group)
|
|
2198
|
+
continue
|
|
2199
|
+
}
|
|
2200
|
+
try {
|
|
2201
|
+
const to = numericTargetValue(key, pending.to, layoutContext)
|
|
2202
|
+
const from =
|
|
2203
|
+
pending.from === undefined
|
|
2204
|
+
? binding.driver.committed(handle)[key]
|
|
2205
|
+
: numericValue(key, pending.from, layoutContext)
|
|
2206
|
+
if (from === undefined) {
|
|
2207
|
+
throw new Error(
|
|
2208
|
+
`L1: cannot flush deferred '${key}': it was not registered on this MotionView controller.`,
|
|
2209
|
+
)
|
|
2210
|
+
}
|
|
2211
|
+
const perProp = perPropTransition(key, to, transition, motionPolicyOf())
|
|
2212
|
+
let group = startGroups.get(transition)
|
|
2213
|
+
if (group === undefined) {
|
|
2214
|
+
group = { targets: {}, resolved: {} }
|
|
2215
|
+
startGroups.set(transition, group)
|
|
2216
|
+
}
|
|
2217
|
+
group.targets[key] = {
|
|
2218
|
+
to,
|
|
2219
|
+
from,
|
|
2220
|
+
...(perProp === undefined ? {} : { transition: perProp }),
|
|
2221
|
+
}
|
|
2222
|
+
group.resolved[key] = to
|
|
2223
|
+
} catch (error) {
|
|
2224
|
+
if (isDeferrableElementMeasureError(error)) {
|
|
2225
|
+
retainHeld[key] = pending
|
|
2226
|
+
continue
|
|
2227
|
+
}
|
|
2228
|
+
if (!isMeasureResolveBoundaryError(error)) throw error
|
|
2229
|
+
if (severity === 'development') throw error
|
|
2230
|
+
report(error)
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
if (
|
|
2234
|
+
startGroups.size === 0 &&
|
|
2235
|
+
flushTargetGroups.size === 0 &&
|
|
2236
|
+
Object.keys(settleTargets).length === 0
|
|
2237
|
+
) {
|
|
2238
|
+
pendingDeferredAnimate = Object.keys(retainHeld).length === 0 ? null : retainHeld
|
|
2239
|
+
if (pendingDeferredAnimate === null) {
|
|
2240
|
+
pendingDeferredTransition = undefined
|
|
2241
|
+
mountDeferredNeedsInstantFlush = false
|
|
2242
|
+
}
|
|
2243
|
+
// Everything still held — keep pending; do not clear mount instant latch.
|
|
2244
|
+
return false
|
|
2245
|
+
}
|
|
2246
|
+
// Origin-only settles ride ONE instant retarget — the measured registration, never an
|
|
2247
|
+
// entrance (review 2bd345e800ff). The settle rests at auto ONLY when its origin IS a
|
|
2248
|
+
// dimension auto (review 1578a42974bc); a RELATIVE origin-only settle keeps its authored
|
|
2249
|
+
// relative form in the relative-rest lane instead (review 7cec8ad963d8) — never the auto
|
|
2250
|
+
// lane — so a later context change re-measures it.
|
|
2251
|
+
if (Object.keys(settleTargets).length > 0) {
|
|
2252
|
+
for (const key of Object.keys(settleTargets)) {
|
|
2253
|
+
const pendingFrom = pendingAll[key]?.from
|
|
2254
|
+
if ((key === 'width' || key === 'height') && restsAtAutoForm(pendingFrom)) {
|
|
2255
|
+
autoRestKeys.add(key)
|
|
2256
|
+
relativeRestForms.delete(key)
|
|
2257
|
+
} else if (isRelativeLengthForm(pendingFrom)) {
|
|
2258
|
+
autoRestKeys.delete(key)
|
|
2259
|
+
relativeRestForms.set(key, pendingFrom)
|
|
2260
|
+
} else {
|
|
2261
|
+
autoRestKeys.delete(key)
|
|
2262
|
+
relativeRestForms.delete(key)
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
commandThroughSeverity(handle, {
|
|
2266
|
+
kind: 'retarget',
|
|
2267
|
+
targets: settleTargets,
|
|
2268
|
+
transition: INSTANT_TRANSITION,
|
|
2269
|
+
})
|
|
2270
|
+
current = { ...current, ...settleTargets }
|
|
2271
|
+
}
|
|
2272
|
+
for (const [transition, group] of startGroups) {
|
|
2273
|
+
for (const key of Object.keys(group.targets)) {
|
|
2274
|
+
const pendingTo = pendingAll[key]?.to
|
|
2275
|
+
if (restsAtAutoForm(pendingTo)) autoRestKeys.add(key)
|
|
2276
|
+
else autoRestKeys.delete(key)
|
|
2277
|
+
// The flush's start lane commands the array too (review 551ba4344486): record the
|
|
2278
|
+
// authored keyframes so a later context-only re-resolution of the SAME array reads as
|
|
2279
|
+
// a responsive final-value retarget, never a first-keyframe replay. A non-array
|
|
2280
|
+
// pending takes the key out of array authorship (same law as the update lane).
|
|
2281
|
+
if (Array.isArray(pendingTo)) lastCommandedRawKeyframes.set(key, pendingTo)
|
|
2282
|
+
else lastCommandedRawKeyframes.delete(key)
|
|
2283
|
+
}
|
|
2284
|
+
commandThroughSeverity(handle, {
|
|
2285
|
+
kind: 'start',
|
|
2286
|
+
targets: group.targets,
|
|
2287
|
+
transition: transition ?? {},
|
|
2288
|
+
})
|
|
2289
|
+
current = { ...current, ...group.resolved }
|
|
2290
|
+
}
|
|
2291
|
+
pendingDeferredAnimate = Object.keys(retainHeld).length === 0 ? null : retainHeld
|
|
2292
|
+
if (pendingDeferredAnimate === null) {
|
|
2293
|
+
pendingDeferredTransition = undefined
|
|
2294
|
+
mountDeferredNeedsInstantFlush = false
|
|
2295
|
+
}
|
|
2296
|
+
let commanded = startGroups.size > 0 || Object.keys(settleTargets).length > 0
|
|
2297
|
+
for (const [transition, flushTarget] of flushTargetGroups) {
|
|
2298
|
+
commanded =
|
|
2299
|
+
this.update({
|
|
2300
|
+
animate: flushTarget as Target,
|
|
2301
|
+
animateScope: 'partial',
|
|
2302
|
+
// This re-entry resolves auto against the measure the flush just seeded — the
|
|
2303
|
+
// retarget invalidation must not drop it back into the deferred lane (review
|
|
2304
|
+
// 356417a77aec).
|
|
2305
|
+
skipAutoMeasureInvalidation: true,
|
|
2306
|
+
...(transition === undefined ? {} : { transition }),
|
|
2307
|
+
}) || commanded
|
|
2308
|
+
}
|
|
2309
|
+
return commanded
|
|
2310
|
+
},
|
|
2311
|
+
|
|
2312
|
+
remeasureAutoRests() {
|
|
2313
|
+
if (handle === null || (autoRestKeys.size === 0 && relativeRestForms.size === 0)) {
|
|
2314
|
+
return false
|
|
2315
|
+
}
|
|
2316
|
+
const layoutContext = lengthLayoutOf()
|
|
2317
|
+
const reseed: Record<string, number> = {}
|
|
2318
|
+
for (const key of autoRestKeys) {
|
|
2319
|
+
// A key mid-animation keeps its course — the drift re-seed applies only at rest.
|
|
2320
|
+
const active = binding.propertyStateFor?.(handle, key)?.active.value === true
|
|
2321
|
+
if (active) continue
|
|
2322
|
+
try {
|
|
2323
|
+
const measured = resolveLengthToPx('auto', key as PositionalLengthAxis, layoutContext)
|
|
2324
|
+
if (current[key] !== measured) reseed[key] = measured
|
|
2325
|
+
} catch (error) {
|
|
2326
|
+
if (isDeferrableElementMeasureError(error)) continue
|
|
2327
|
+
if (!isMeasureResolveBoundaryError(error)) throw error
|
|
2328
|
+
if (severity === 'development') throw error
|
|
2329
|
+
report(error)
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
// The relative-rest lane (review 7cec8ad963d8): initial-only %/calc/vw/vh forms re-resolve
|
|
2333
|
+
// against the changed element box / parent / viewport and INSTANT re-seed.
|
|
2334
|
+
for (const [key, rawForm] of relativeRestForms) {
|
|
2335
|
+
const active = binding.propertyStateFor?.(handle, key)?.active.value === true
|
|
2336
|
+
if (active) continue
|
|
2337
|
+
try {
|
|
2338
|
+
const measured = numericValue(key, rawForm, layoutContext)
|
|
2339
|
+
if (current[key] !== measured) reseed[key] = measured
|
|
2340
|
+
} catch (error) {
|
|
2341
|
+
if (isDeferrableElementMeasureError(error)) continue
|
|
2342
|
+
if (!isMeasureResolveBoundaryError(error)) throw error
|
|
2343
|
+
if (severity === 'development') throw error
|
|
2344
|
+
report(error)
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
2347
|
+
if (Object.keys(reseed).length === 0) return false
|
|
2348
|
+
commandThroughSeverity(handle, {
|
|
2349
|
+
kind: 'retarget',
|
|
2350
|
+
targets: reseed,
|
|
2351
|
+
transition: INSTANT_TRANSITION,
|
|
2352
|
+
})
|
|
2353
|
+
current = { ...current, ...reseed }
|
|
2354
|
+
return true
|
|
2355
|
+
},
|
|
2356
|
+
|
|
2357
|
+
syncDeferredLengthPending(next) {
|
|
2358
|
+
commandLaneIsDeclarative = next.animateScope !== undefined
|
|
2359
|
+
// Pending-only: no filterStartCompat against element keyframe timing, no driver commands.
|
|
2360
|
+
// Label full projection uses this after per-label updates (major 210b429d9557).
|
|
2361
|
+
if (handle === null) {
|
|
2362
|
+
throw new Error('MotionView controller used before mount(): mount the component first.')
|
|
2363
|
+
}
|
|
2364
|
+
const animateShapeRefusal = targetShapeRefusal(next.animate, { componentId })
|
|
2365
|
+
if (animateShapeRefusal !== null) throw animateShapeRefusal
|
|
2366
|
+
const animateSnapshot = materializeTargetArrays(next.animate, componentId)
|
|
2367
|
+
validateAgainstNativeHost(animateSnapshot, componentId)
|
|
2368
|
+
// Mount-refused keys are terminal for this mount (review 39c6a3e7ee90): the sync must not
|
|
2369
|
+
// rebuild pending for them either.
|
|
2370
|
+
const animateEffective = dropMountRefusedKeys(animateSnapshot, mountRefusedKeys)
|
|
2371
|
+
const { deferred: stillDeferred } = resolveAnimateTargets(
|
|
2372
|
+
animateEffective,
|
|
2373
|
+
lengthLayoutOf(),
|
|
2374
|
+
severity,
|
|
2375
|
+
report,
|
|
2376
|
+
)
|
|
2377
|
+
const animateScope = next.animateScope ?? 'partial'
|
|
2378
|
+
if (animateScope === 'full') {
|
|
2379
|
+
const nextPending: PendingDeferredLengthTargets = {}
|
|
2380
|
+
for (const key of Object.keys(stillDeferred)) {
|
|
2381
|
+
nextPending[key] = { ...pendingDeferredAnimate?.[key], to: stillDeferred[key] }
|
|
2382
|
+
}
|
|
2383
|
+
// Origin pairs (review 73044cc0e1bd): a projection key holding a pending measured
|
|
2384
|
+
// origin keeps its pair — the full-projection abandon law applies to keys the
|
|
2385
|
+
// projection OMITS, never to origins it still names. The pair is SPREAD, never
|
|
2386
|
+
// reconstructed (review 759cc100e882): the per-label update stamped its OWN transition
|
|
2387
|
+
// on the entry, and the full sync must not launder it back to the mount fallback.
|
|
2388
|
+
for (const key of Object.keys(animateEffective as Target)) {
|
|
2389
|
+
if (key in stillDeferred) continue
|
|
2390
|
+
const prior = pendingDeferredAnimate?.[key]
|
|
2391
|
+
if (prior?.from !== undefined) {
|
|
2392
|
+
nextPending[key] = {
|
|
2393
|
+
...prior,
|
|
2394
|
+
to: (animateEffective as Record<string, unknown>)[key],
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
pendingDeferredAnimate = Object.keys(nextPending).length === 0 ? null : nextPending
|
|
2399
|
+
if (pendingDeferredAnimate === null) {
|
|
2400
|
+
pendingDeferredTransition = undefined
|
|
2401
|
+
mountDeferredNeedsInstantFlush = false
|
|
2402
|
+
}
|
|
2403
|
+
// Do not clear mountDeferredNeedsInstantFlush otherwise — re-sync before first
|
|
2404
|
+
// onLayout must preserve the mount initial:false instant latch
|
|
2405
|
+
// (review-1785230277710-lhz6ub).
|
|
2406
|
+
} else {
|
|
2407
|
+
const nextPending: PendingDeferredLengthTargets = { ...pendingDeferredAnimate }
|
|
2408
|
+
for (const key of Object.keys(animateEffective as Target)) {
|
|
2409
|
+
if (key in stillDeferred) {
|
|
2410
|
+
nextPending[key] = { ...pendingDeferredAnimate?.[key], to: stillDeferred[key] }
|
|
2411
|
+
} else {
|
|
2412
|
+
const prior = pendingDeferredAnimate?.[key]
|
|
2413
|
+
if (prior?.from !== undefined) {
|
|
2414
|
+
// Spread preserves the stamped transition (review 759cc100e882) — the per-label
|
|
2415
|
+
// application's own transition rides into the flush, not the mount fallback.
|
|
2416
|
+
nextPending[key] = {
|
|
2417
|
+
...prior,
|
|
2418
|
+
to: (animateEffective as Record<string, unknown>)[key],
|
|
2419
|
+
}
|
|
2420
|
+
} else delete nextPending[key]
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
pendingDeferredAnimate = Object.keys(nextPending).length === 0 ? null : nextPending
|
|
2424
|
+
if (pendingDeferredAnimate === null) {
|
|
2425
|
+
pendingDeferredTransition = undefined
|
|
2426
|
+
mountDeferredNeedsInstantFlush = false
|
|
2427
|
+
}
|
|
2428
|
+
}
|
|
2429
|
+
},
|
|
2430
|
+
|
|
2431
|
+
update(next) {
|
|
2432
|
+
commandLaneIsDeclarative = next.animateScope !== undefined
|
|
2433
|
+
const h = requireHandle()
|
|
2434
|
+
if (next.removalValues !== activeRemovalValues) {
|
|
2435
|
+
activeRemovalValues = next.removalValues
|
|
2436
|
+
handedOffRemovalKeys.clear()
|
|
2437
|
+
}
|
|
2438
|
+
// CAPTURE + validate the transition ONCE (review majors 51/61): this update reads its transition
|
|
2439
|
+
// for the R8-F1 filter, per-prop defaults, AND up to THREE driver commands (array start, scalar
|
|
2440
|
+
// retarget, color start) that each re-capture. The frozen snapshot is what validation AND every
|
|
2441
|
+
// command see — a malicious accessor cannot skew the lanes/drivers or slip an unvalidated value.
|
|
2442
|
+
// Materialize the animate arrays ONCE, then validate the SNAPSHOT (review major 60): shape gate
|
|
2443
|
+
// first (no element read), then one frozen snapshot for validation, filtering, resolution, and the
|
|
2444
|
+
// diff base — a direct consumer's accessor-backed array can't validate one value and forward another.
|
|
2445
|
+
const animateShapeRefusal = targetShapeRefusal(next.animate, { componentId })
|
|
2446
|
+
if (animateShapeRefusal !== null) throw animateShapeRefusal
|
|
2447
|
+
const authoredTransition = captureAndValidateTransition(
|
|
2448
|
+
next.transition,
|
|
2449
|
+
componentId,
|
|
2450
|
+
Object.keys(next.animate),
|
|
2451
|
+
)
|
|
2452
|
+
// T21 (REQ-API-053): the orchestration-computed delay seeds the VALIDATED snapshot as the
|
|
2453
|
+
// per-key default — authored delays at every altitude win by the seeding spread, and the
|
|
2454
|
+
// downstream lanes (flat command transition, map selection, count-aware default merge)
|
|
2455
|
+
// execute it through the shipped T18-b delay machinery unchanged.
|
|
2456
|
+
const transition =
|
|
2457
|
+
next.orchestrationDelaySeconds === undefined
|
|
2458
|
+
? authoredTransition
|
|
2459
|
+
: seedOrchestrationDelay(authoredTransition, next.orchestrationDelaySeconds)
|
|
2460
|
+
const animateSnapshot = materializeTargetArrays(next.animate, componentId)
|
|
2461
|
+
validateAgainstNativeHost(animateSnapshot, componentId)
|
|
2462
|
+
// Filter R8-F1-incompatible arrays under severity FIRST (major 42): an indirect consumer (a
|
|
2463
|
+
// gesture retarget batch riding the element-transition fallback) reaches update() past its
|
|
2464
|
+
// supplying gate — report + drop the property (production) or throw (development) before any diff.
|
|
2465
|
+
// Mount-refused keys are terminal for this mount (review 39c6a3e7ee90): they drop out of
|
|
2466
|
+
// every lane here instead of crashing the fixed-at-mount invariant on a later update.
|
|
2467
|
+
const compatAnimate = dropMountRefusedKeys(
|
|
2468
|
+
filterStartCompat(animateSnapshot, transition, severity, report, componentId),
|
|
2469
|
+
mountRefusedKeys,
|
|
2470
|
+
)
|
|
2471
|
+
// numeric→auto / auto→auto retarget re-measure (review 356417a77aec): a cached auto
|
|
2472
|
+
// measure is STALE the moment an auto target arrives for that dimension — it may have
|
|
2473
|
+
// been seeded from a constraint episode (numeric onLayout) or from content that since
|
|
2474
|
+
// changed. Dropping it re-enters the deferred lane so the host drops the painted
|
|
2475
|
+
// constraint and re-measures UNCONSTRAINED content; the flush then commands the fresh
|
|
2476
|
+
// measure. The deferred flush's own re-entry opts out — it resolves auto against the
|
|
2477
|
+
// measure it just seeded, and the pending queue was already drained before re-entry.
|
|
2478
|
+
if (next.skipAutoMeasureInvalidation !== true && hooks.lengthLayoutContextBox !== undefined) {
|
|
2479
|
+
const autoBox = hooks.lengthLayoutContextBox
|
|
2480
|
+
let nextBox: LengthLayoutContext | null = null
|
|
2481
|
+
for (const key of Object.keys(compatAnimate)) {
|
|
2482
|
+
if (key !== 'width' && key !== 'height') continue
|
|
2483
|
+
if (!containsAutoLengthTarget((compatAnimate as Record<string, unknown>)[key])) continue
|
|
2484
|
+
if (containsAutoLengthTarget(pendingDeferredAnimate?.[key]?.to)) continue
|
|
2485
|
+
if (nextBox === null) nextBox = { ...autoBox.current }
|
|
2486
|
+
if (key === 'width') {
|
|
2487
|
+
const { autoWidth: _dropped, ...rest } = nextBox
|
|
2488
|
+
nextBox = rest
|
|
2489
|
+
} else {
|
|
2490
|
+
const { autoHeight: _dropped, ...rest } = nextBox
|
|
2491
|
+
nextBox = rest
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
if (nextBox !== null) autoBox.current = nextBox
|
|
2495
|
+
}
|
|
2496
|
+
const { resolved, deferred: stillDeferred } = resolveAnimateTargets(
|
|
2497
|
+
compatAnimate,
|
|
2498
|
+
lengthLayoutOf(),
|
|
2499
|
+
severity,
|
|
2500
|
+
report,
|
|
2501
|
+
)
|
|
2502
|
+
// Pending-origin pairing (review 73044cc0e1bd): an incoming RESOLVED key whose pending
|
|
2503
|
+
// entry holds a measured origin (an initial-only mount pending, or a paired mount
|
|
2504
|
+
// origin) never retargets from the temporary 0 seed. An origin that measures NOW starts
|
|
2505
|
+
// the pair immediately; one still needing the element box pairs into pending and waits
|
|
2506
|
+
// for the flush. A boundary-refused origin is reported and dropped (production) — the
|
|
2507
|
+
// target then rides the ordinary scalar lane.
|
|
2508
|
+
const deferredPairKeys = new Set<string>()
|
|
2509
|
+
const pairedStarts: Record<string, PropTarget> = {}
|
|
2510
|
+
for (const key of Object.keys(resolved)) {
|
|
2511
|
+
if (key in stillDeferred) continue
|
|
2512
|
+
const prior = pendingDeferredAnimate?.[key]
|
|
2513
|
+
if (prior?.from === undefined) continue
|
|
2514
|
+
try {
|
|
2515
|
+
const from = numericValue(key, prior.from, lengthLayoutOf())
|
|
2516
|
+
const to = resolved[key]!
|
|
2517
|
+
const perProp = perPropTransition(key, to, transition, motionPolicyOf())
|
|
2518
|
+
pairedStarts[key] = {
|
|
2519
|
+
to,
|
|
2520
|
+
from,
|
|
2521
|
+
...(perProp === undefined ? {} : { transition: perProp }),
|
|
2522
|
+
}
|
|
2523
|
+
} catch (error) {
|
|
2524
|
+
if (isDeferrableElementMeasureError(error)) {
|
|
2525
|
+
deferredPairKeys.add(key)
|
|
2526
|
+
continue
|
|
2527
|
+
}
|
|
2528
|
+
if (!isMeasureResolveBoundaryError(error)) throw error
|
|
2529
|
+
if (severity === 'development') throw error
|
|
2530
|
+
report(error)
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
// Deferred pending queue (majors 9044e8b70004 + 7c3a91e2b0d4):
|
|
2534
|
+
// - supersession: keys resolved in this update leave pending
|
|
2535
|
+
// - stillDeferred keys re-enter / stay
|
|
2536
|
+
// - partial updates (gesture/label): absence does NOT abandon other pending keys
|
|
2537
|
+
// - full declarative animate: rebuild from stillDeferred only (absent = abandoned)
|
|
2538
|
+
// Mount seeds pending; flush drains it. Transition OWNERSHIP is per entry (review
|
|
2539
|
+
// 93aeec05a9a8): this update's transition stamps only the keys IT defers or pairs —
|
|
2540
|
+
// and an update with NO authored transition stamps the explicit default (review
|
|
2541
|
+
// 7d395c90c954): supersession always transfers ownership, never retains the prior
|
|
2542
|
+
// application's transition.
|
|
2543
|
+
const stampTransition = (pair: PendingDeferredLengthTarget): PendingDeferredLengthTarget =>
|
|
2544
|
+
transition === undefined ? { ...pair, transition: null } : { ...pair, transition }
|
|
2545
|
+
const animateScope = next.animateScope ?? 'partial'
|
|
2546
|
+
if (animateScope === 'full') {
|
|
2547
|
+
const nextPending: PendingDeferredLengthTargets = {}
|
|
2548
|
+
for (const key of Object.keys(stillDeferred)) {
|
|
2549
|
+
nextPending[key] = stampTransition({
|
|
2550
|
+
...pendingDeferredAnimate?.[key],
|
|
2551
|
+
to: stillDeferred[key],
|
|
2552
|
+
})
|
|
2553
|
+
}
|
|
2554
|
+
for (const key of deferredPairKeys) {
|
|
2555
|
+
nextPending[key] = stampTransition({
|
|
2556
|
+
to: (compatAnimate as Record<string, unknown>)[key],
|
|
2557
|
+
from: pendingDeferredAnimate![key]!.from,
|
|
2558
|
+
})
|
|
2559
|
+
}
|
|
2560
|
+
pendingDeferredAnimate = Object.keys(nextPending).length === 0 ? null : nextPending
|
|
2561
|
+
// Re-defer with a commanding update is post-mount — clear mount instant latch (and a
|
|
2562
|
+
// supersession / empty full snapshot drains pending without flush — same latch end).
|
|
2563
|
+
mountDeferredNeedsInstantFlush = false
|
|
2564
|
+
} else {
|
|
2565
|
+
const nextPending: PendingDeferredLengthTargets = {
|
|
2566
|
+
...pendingDeferredAnimate,
|
|
2567
|
+
}
|
|
2568
|
+
for (const key of Object.keys(compatAnimate)) {
|
|
2569
|
+
if (key in stillDeferred) {
|
|
2570
|
+
nextPending[key] = stampTransition({
|
|
2571
|
+
...pendingDeferredAnimate?.[key],
|
|
2572
|
+
to: stillDeferred[key],
|
|
2573
|
+
})
|
|
2574
|
+
} else if (deferredPairKeys.has(key)) {
|
|
2575
|
+
nextPending[key] = stampTransition({
|
|
2576
|
+
to: (compatAnimate as Record<string, unknown>)[key],
|
|
2577
|
+
from: pendingDeferredAnimate![key]!.from,
|
|
2578
|
+
})
|
|
2579
|
+
} else delete nextPending[key]
|
|
2580
|
+
}
|
|
2581
|
+
pendingDeferredAnimate = Object.keys(nextPending).length === 0 ? null : nextPending
|
|
2582
|
+
if (pendingDeferredAnimate === null) {
|
|
2583
|
+
pendingDeferredTransition = undefined
|
|
2584
|
+
mountDeferredNeedsInstantFlush = false
|
|
2585
|
+
}
|
|
2586
|
+
}
|
|
2587
|
+
const resolvedColors = resolveColors(compatAnimate)
|
|
2588
|
+
const resolvedDiscretes = resolveDiscretes(compatAnimate)
|
|
2589
|
+
// Diff RESOLVED values (M2.2 no-op invariant): scalars by ===, keyframe arrays ELEMENT-WISE
|
|
2590
|
+
// (sameTargetValue) so a new-but-equal array on an unrelated re-render issues no command.
|
|
2591
|
+
// Only changed keys ride a command (unlisted keys hold, REQ-API-002/012). A changed SCALAR
|
|
2592
|
+
// RETARGETS (velocity-continuous); a changed ARRAY re-STARTS — retarget is scalar-only, and a
|
|
2593
|
+
// keyframe array is an absolute sequence Motion restarts on change (R8 M2).
|
|
2594
|
+
const changedScalars: Record<string, number> = {}
|
|
2595
|
+
const changedArrayKeys: string[] = []
|
|
2596
|
+
// Context-only re-resolutions of an unchanged authored array: retarget the resting final
|
|
2597
|
+
// value instead of restarting (review 26cc511b0927) — filled by the array plan below.
|
|
2598
|
+
const responsiveFinalRetargets: Record<string, number> = {}
|
|
2599
|
+
for (const key of Object.keys(resolved)) {
|
|
2600
|
+
// Paired keys never ride the ordinary lanes: a deferred pair waits for the flush, an
|
|
2601
|
+
// already-measurable pair starts below from its measured origin — never the 0 seed.
|
|
2602
|
+
if (deferredPairKeys.has(key) || key in pairedStarts) continue
|
|
2603
|
+
const value = resolved[key]!
|
|
2604
|
+
const prev = current[key]
|
|
2605
|
+
// A removal episode is an execution edge, even when the declarative null-first target is
|
|
2606
|
+
// unchanged: it must restart from the one captured removal origin rather than continue an
|
|
2607
|
+
// older generator/velocity that presence is not measuring (REQ-PRESENCE-020).
|
|
2608
|
+
const removalHandoff =
|
|
2609
|
+
next.removalValues !== undefined &&
|
|
2610
|
+
Array.isArray(value) &&
|
|
2611
|
+
value[0] === null &&
|
|
2612
|
+
!handedOffRemovalKeys.has(key)
|
|
2613
|
+
const equalKeyframeReplay = next.restartEqualKeyframes === true && Array.isArray(value)
|
|
2614
|
+
if (
|
|
2615
|
+
prev !== undefined &&
|
|
2616
|
+
sameTargetValue(prev, value) &&
|
|
2617
|
+
!removalHandoff &&
|
|
2618
|
+
!equalKeyframeReplay
|
|
2619
|
+
)
|
|
2620
|
+
continue
|
|
2621
|
+
if (typeof value === 'number') changedScalars[key] = value
|
|
2622
|
+
else changedArrayKeys.push(key)
|
|
2623
|
+
}
|
|
2624
|
+
// Discrete diff base is the declared keyword (scalar-only lane): unchanged keyword ⇒ no
|
|
2625
|
+
// command (the no-op re-render discipline).
|
|
2626
|
+
const changedDiscreteKeys = Object.keys(resolvedDiscretes).filter(
|
|
2627
|
+
(key) => currentDiscreteCanonical[key] !== resolvedDiscretes[key],
|
|
2628
|
+
)
|
|
2629
|
+
const changedColorKeys = Object.keys(resolvedColors).filter(
|
|
2630
|
+
// Diff the WHOLE declared target canonical (major 37): a changed MIDDLE keyframe re-commands
|
|
2631
|
+
// even when the endpoints match. The no-op invariant still holds — an unrelated re-render
|
|
2632
|
+
// passing the same color/array canonicalizes identically ⇒ no command.
|
|
2633
|
+
(key) => {
|
|
2634
|
+
const target = resolvedColors[key]!
|
|
2635
|
+
return (
|
|
2636
|
+
(next.restartEqualKeyframes === true && isColorArrayTarget(target)) ||
|
|
2637
|
+
currentColorCanonical[key] !== colorTargetCanonical(target) ||
|
|
2638
|
+
(next.removalValues !== undefined &&
|
|
2639
|
+
isColorArrayTarget(target) &&
|
|
2640
|
+
target[0] === null &&
|
|
2641
|
+
!handedOffRemovalKeys.has(key))
|
|
2642
|
+
)
|
|
2643
|
+
},
|
|
2644
|
+
)
|
|
2645
|
+
if (
|
|
2646
|
+
Object.keys(changedScalars).length === 0 &&
|
|
2647
|
+
changedArrayKeys.length === 0 &&
|
|
2648
|
+
changedColorKeys.length === 0 &&
|
|
2649
|
+
changedDiscreteKeys.length === 0 &&
|
|
2650
|
+
Object.keys(pairedStarts).length === 0 &&
|
|
2651
|
+
Object.keys(responsiveFinalRetargets).length === 0
|
|
2652
|
+
)
|
|
2653
|
+
return false
|
|
2654
|
+
// (`transition` — the per-call transition, r9 major c7de6fd1030b; captured once above, major 51.)
|
|
2655
|
+
// The live committed value of a key, or a LOUD refusal if the key was never mounted (major 39:
|
|
2656
|
+
// the array lane silently invented 0 for an unmounted key; the animated set is fixed at mount).
|
|
2657
|
+
let committed: Readonly<Record<string, number>> | undefined
|
|
2658
|
+
const committedValueOf = (key: string): number => {
|
|
2659
|
+
committed ??= binding.driver.committed(h)
|
|
2660
|
+
const value = committed[key]
|
|
2661
|
+
if (value === undefined) {
|
|
2662
|
+
throw new Error(
|
|
2663
|
+
`L1: cannot update '${key}': it was not mounted on this element (the animated key set ` +
|
|
2664
|
+
'is fixed at mount, REQ-API-002/012).',
|
|
2665
|
+
)
|
|
2666
|
+
}
|
|
2667
|
+
return value
|
|
2668
|
+
}
|
|
2669
|
+
const numericFromCurrent = (key: string): number => {
|
|
2670
|
+
if (next.removalValues === undefined) return committedValueOf(key)
|
|
2671
|
+
const value = next.removalValues[key]
|
|
2672
|
+
if (typeof value !== 'number') {
|
|
2673
|
+
throw new Error(
|
|
2674
|
+
`presence removal snapshot '${key}' must be numeric for a null-first numeric target ` +
|
|
2675
|
+
`(received ${typeof value}; REQ-PRESENCE-020).`,
|
|
2676
|
+
)
|
|
2677
|
+
}
|
|
2678
|
+
return value
|
|
2679
|
+
}
|
|
2680
|
+
const colorFromCurrent = (key: string, live: readonly RGBA[]): RGBA => {
|
|
2681
|
+
if (next.removalValues === undefined) {
|
|
2682
|
+
return parseColor(projectColorSequence(live, committedValueOf(key)))
|
|
2683
|
+
}
|
|
2684
|
+
const value = next.removalValues[key]
|
|
2685
|
+
if (typeof value !== 'string') {
|
|
2686
|
+
throw new Error(
|
|
2687
|
+
`presence removal snapshot '${key}' must be a color string for a null-first color target ` +
|
|
2688
|
+
`(received ${typeof value}; REQ-PRESENCE-020).`,
|
|
2689
|
+
)
|
|
2690
|
+
}
|
|
2691
|
+
return parseColor(value)
|
|
2692
|
+
}
|
|
2693
|
+
// BUILD every plan purely, up front, so the whole update is ATOMIC (major 36): the numeric-array
|
|
2694
|
+
// start + the color start are the driver's only FALLIBLE commands (an R8-F1 >2-keyframe explicit
|
|
2695
|
+
// spring). Pre-validating them BEFORE any command/seed means a rejection mutates NOTHING — no
|
|
2696
|
+
// scalar retarget, no endpoint seed, no `current` skew. A keyframe array re-STARTS (retarget is
|
|
2697
|
+
// scalar-only) from the live committed value: continuity for a null-first array; a non-null-first
|
|
2698
|
+
// array's tick-0 is keyframes[0] regardless. EXCEPTION (review 26cc511b0927): the SAME authored
|
|
2699
|
+
// array re-resolving against changed context is a responsive remeasure, not a replacement —
|
|
2700
|
+
// its resting final value RETARGETS (scalar lane, no first-keyframe replay).
|
|
2701
|
+
const arrayTargets: Record<string, PropTarget> = {}
|
|
2702
|
+
for (const key of changedArrayKeys) {
|
|
2703
|
+
const to = resolved[key]! as PropTargetTo
|
|
2704
|
+
const raw = (compatAnimate as Record<string, unknown>)[key]
|
|
2705
|
+
const lastRaw = lastCommandedRawKeyframes.get(key)
|
|
2706
|
+
// The removal handoff takes precedence over the responsive-retarget shortcut (review
|
|
2707
|
+
// f48c90b9ad72): the SAME already-commanded null-first relative array as the exit target
|
|
2708
|
+
// must restart from the captured REQ-PRESENCE-020 drop-time origin — never continue the
|
|
2709
|
+
// older live flight presence is not measuring.
|
|
2710
|
+
const removalHandoff =
|
|
2711
|
+
next.removalValues !== undefined &&
|
|
2712
|
+
Array.isArray(raw) &&
|
|
2713
|
+
raw[0] === null &&
|
|
2714
|
+
!handedOffRemovalKeys.has(key)
|
|
2715
|
+
if (
|
|
2716
|
+
!removalHandoff &&
|
|
2717
|
+
next.restartEqualKeyframes !== true &&
|
|
2718
|
+
lastRaw !== undefined &&
|
|
2719
|
+
sameRawKeyframes(lastRaw, raw) &&
|
|
2720
|
+
Array.isArray(raw) &&
|
|
2721
|
+
restsAtRelativeForm(raw) &&
|
|
2722
|
+
Array.isArray(to) &&
|
|
2723
|
+
typeof to[to.length - 1] === 'number'
|
|
2724
|
+
) {
|
|
2725
|
+
responsiveFinalRetargets[key] = to[to.length - 1] as number
|
|
2726
|
+
lastCommandedRawKeyframes.set(key, raw)
|
|
2727
|
+
continue
|
|
2728
|
+
}
|
|
2729
|
+
const from =
|
|
2730
|
+
Array.isArray(to) && to[0] === null ? numericFromCurrent(key) : committedValueOf(key)
|
|
2731
|
+
const perProp = perPropTransition(key, to, transition, motionPolicyOf())
|
|
2732
|
+
arrayTargets[key] = { to, from, ...(perProp !== undefined ? { transition: perProp } : {}) }
|
|
2733
|
+
if (Array.isArray(raw)) lastCommandedRawKeyframes.set(key, raw)
|
|
2734
|
+
}
|
|
2735
|
+
// L1 §semantics 5, oracle-faithful: re-seed from the CURRENT DISPLAYED color (C0 — the
|
|
2736
|
+
// handoff frame is byte-continuous) through the live sequence and restart progress 0→100.
|
|
2737
|
+
// Velocity is not transferable across endpoint pairs for mixed values — motion's own mixed-value
|
|
2738
|
+
// interrupts restart the same way. Projection clamps/rounds overshot RGBA before it becomes the
|
|
2739
|
+
// next committed origin, so interruption starts from exactly the frame React Native displayed.
|
|
2740
|
+
const colorTargets: Record<string, PropTarget> = {}
|
|
2741
|
+
const pendingColorSeeds: {
|
|
2742
|
+
key: string
|
|
2743
|
+
declaredTarget: ColorTarget
|
|
2744
|
+
sequence: readonly RGBA[]
|
|
2745
|
+
}[] = []
|
|
2746
|
+
// The discrete mirror (T23 B2b): re-seed from the keyword DISPLAYED at the live progress
|
|
2747
|
+
// (projectDiscreteStep — the same seam the animated style binds) and restart 0→100.
|
|
2748
|
+
const discreteTargets: Record<string, PropTarget> = {}
|
|
2749
|
+
const pendingDiscreteSeeds: {
|
|
2750
|
+
key: string
|
|
2751
|
+
declaredTarget: string
|
|
2752
|
+
pair: readonly [string, string]
|
|
2753
|
+
}[] = []
|
|
2754
|
+
for (const key of changedDiscreteKeys) {
|
|
2755
|
+
const live = discretes[key]
|
|
2756
|
+
if (live === undefined) {
|
|
2757
|
+
// A mount-refused discrete start (T23 B2a2) is terminal for the key: the one
|
|
2758
|
+
// report fired at mount; retargets skip silently (the numeric refused-seed law).
|
|
2759
|
+
if (mountRefusedKeys.has(key)) continue
|
|
2760
|
+
throw new Error(`T23: discrete '${key}' has no registered pair on this element.`)
|
|
2761
|
+
}
|
|
2762
|
+
const target = resolvedDiscretes[key]!
|
|
2763
|
+
const liveKeyword = projectDiscreteStep(live[0], live[1], committedValueOf(key))
|
|
2764
|
+
const pair: readonly [string, string] = [liveKeyword, target]
|
|
2765
|
+
pendingDiscreteSeeds.push({
|
|
2766
|
+
key,
|
|
2767
|
+
declaredTarget: target,
|
|
2768
|
+
pair,
|
|
2769
|
+
})
|
|
2770
|
+
const perProp = perPropTransition(key, PROGRESS_END, transition, motionPolicyOf())
|
|
2771
|
+
discreteTargets[key] = {
|
|
2772
|
+
to: PROGRESS_END,
|
|
2773
|
+
from: PROGRESS_START,
|
|
2774
|
+
...(perProp !== undefined ? { transition: perProp } : {}),
|
|
2775
|
+
}
|
|
2776
|
+
}
|
|
2777
|
+
for (const key of changedColorKeys) {
|
|
2778
|
+
const live = colors[key]
|
|
2779
|
+
if (live === undefined) {
|
|
2780
|
+
throw new Error(`L1: color '${key}' has no registered sequence on this element.`)
|
|
2781
|
+
}
|
|
2782
|
+
const target = resolvedColors[key]!
|
|
2783
|
+
const nullFirst = isColorArrayTarget(target) && target[0] === null
|
|
2784
|
+
const liveColor = nullFirst
|
|
2785
|
+
? colorFromCurrent(key, live)
|
|
2786
|
+
: parseColor(projectColorSequence(live, committedValueOf(key)))
|
|
2787
|
+
pendingColorSeeds.push({
|
|
2788
|
+
key,
|
|
2789
|
+
declaredTarget: target,
|
|
2790
|
+
sequence: buildColorSequence(target, liveColor),
|
|
2791
|
+
})
|
|
2792
|
+
if (isColorArrayTarget(target)) {
|
|
2793
|
+
const progressTo = colorProgressArray(target.length)
|
|
2794
|
+
const perProp = perPropTransition(key, progressTo, transition, motionPolicyOf())
|
|
2795
|
+
colorTargets[key] = {
|
|
2796
|
+
to: progressTo,
|
|
2797
|
+
from: PROGRESS_START,
|
|
2798
|
+
...(perProp !== undefined ? { transition: perProp } : {}),
|
|
2799
|
+
}
|
|
2800
|
+
} else {
|
|
2801
|
+
// Mirror mount: scalar colors must carry perPropTransition so skipAnimations/isStatic
|
|
2802
|
+
// force INSTANT on retarget (REQ-API-035 law (d); review r2 major 4). Pre-fix this
|
|
2803
|
+
// branch emitted bare {to, from} and the color start rode the command-level timed tween.
|
|
2804
|
+
const perProp = perPropTransition(key, PROGRESS_END, transition, motionPolicyOf())
|
|
2805
|
+
colorTargets[key] = {
|
|
2806
|
+
to: PROGRESS_END,
|
|
2807
|
+
from: PROGRESS_START,
|
|
2808
|
+
...(perProp !== undefined ? { transition: perProp } : {}),
|
|
2809
|
+
}
|
|
2810
|
+
}
|
|
2811
|
+
}
|
|
2812
|
+
// Every changed SCALAR key must be MOUNTED too, proven BEFORE any command (review major 52): the
|
|
2813
|
+
// array/color lanes proved their keys in the build phase above (committedValueOf), but an unmounted
|
|
2814
|
+
// scalar was previously left to the driver's retarget — which threw AFTER the array start had
|
|
2815
|
+
// already committed (non-atomic). The fixed-at-mount set is the authority (a mounted-but-unstepped
|
|
2816
|
+
// key is absent from `committed`, so `committed` alone can't decide membership).
|
|
2817
|
+
for (const key of Object.keys(changedScalars)) {
|
|
2818
|
+
if (!registeredKeys.has(key)) {
|
|
2819
|
+
throw new Error(
|
|
2820
|
+
`L1: cannot update '${key}': it was not mounted on this element (the animated key set ` +
|
|
2821
|
+
'is fixed at mount, REQ-API-002/012).',
|
|
2822
|
+
)
|
|
2823
|
+
}
|
|
2824
|
+
}
|
|
2825
|
+
// R8-F1-incompatible arrays were filtered up-front (compatAnimate) so neither arrayTargets nor
|
|
2826
|
+
// colorTargets can hold one — every command below is infallible (major 36 atomicity).
|
|
2827
|
+
// COMMIT: the color seeds publish before the color start.
|
|
2828
|
+
// REQ-API-035: retarget carries a single command-level transition (no per-key slot). Split the
|
|
2829
|
+
// batch when reducedMotion applies so transform keys force INSTANT while non-transforms keep the
|
|
2830
|
+
// timed transition (review r1 major 2: every(TRANSFORM) left mixed batches fully animated).
|
|
2831
|
+
// skipAnimations / isStatic force INSTANT on every key.
|
|
2832
|
+
const scalarKeys = Object.keys(changedScalars)
|
|
2833
|
+
const livePolicy = motionPolicyOf()
|
|
2834
|
+
const baseRetargetTransition = transition ?? {}
|
|
2835
|
+
// Paired starts carry their measured origin explicitly (review 73044cc0e1bd) — they join
|
|
2836
|
+
// the start command rather than retargeting from the temporary seed.
|
|
2837
|
+
const startTargets: Record<string, PropTarget> = { ...pairedStarts, ...arrayTargets }
|
|
2838
|
+
if (Object.keys(startTargets).length > 0) {
|
|
2839
|
+
commandThroughSeverity(h, {
|
|
2840
|
+
kind: 'start',
|
|
2841
|
+
targets: startTargets,
|
|
2842
|
+
transition: transition ?? {},
|
|
2843
|
+
})
|
|
2844
|
+
}
|
|
2845
|
+
// Responsive remeasure (review 26cc511b0927): the SAME authored array re-resolved against
|
|
2846
|
+
// changed context — retarget the resting final value, never a sequence restart from the
|
|
2847
|
+
// first keyframe. The retarget rides the SAME livePolicy law as the scalar lane (review
|
|
2848
|
+
// 5c9810960d1a): skipAnimations/isStatic force INSTANT everywhere; shouldReduceMotion
|
|
2849
|
+
// forces INSTANT on transform keys and keeps the timed transition elsewhere.
|
|
2850
|
+
const responsiveKeys = Object.keys(responsiveFinalRetargets)
|
|
2851
|
+
if (responsiveKeys.length > 0) {
|
|
2852
|
+
if (livePolicy.skipAnimations) {
|
|
2853
|
+
commandThroughSeverity(h, {
|
|
2854
|
+
kind: 'retarget',
|
|
2855
|
+
targets: responsiveFinalRetargets,
|
|
2856
|
+
transition: INSTANT_TRANSITION,
|
|
2857
|
+
})
|
|
2858
|
+
} else if (livePolicy.shouldReduceMotion) {
|
|
2859
|
+
const transformTargets: Record<string, number> = {}
|
|
2860
|
+
const nonTransformTargets: Record<string, number> = {}
|
|
2861
|
+
for (const key of responsiveKeys) {
|
|
2862
|
+
if (TRANSFORM_KEY_SET.has(key)) transformTargets[key] = responsiveFinalRetargets[key]!
|
|
2863
|
+
else nonTransformTargets[key] = responsiveFinalRetargets[key]!
|
|
2864
|
+
}
|
|
2865
|
+
if (Object.keys(transformTargets).length > 0) {
|
|
2866
|
+
commandThroughSeverity(h, {
|
|
2867
|
+
kind: 'retarget',
|
|
2868
|
+
targets: transformTargets,
|
|
2869
|
+
transition: INSTANT_TRANSITION,
|
|
2870
|
+
})
|
|
2871
|
+
}
|
|
2872
|
+
if (Object.keys(nonTransformTargets).length > 0) {
|
|
2873
|
+
commandThroughSeverity(h, {
|
|
2874
|
+
kind: 'retarget',
|
|
2875
|
+
targets: nonTransformTargets,
|
|
2876
|
+
transition: baseRetargetTransition,
|
|
2877
|
+
})
|
|
2878
|
+
}
|
|
2879
|
+
} else {
|
|
2880
|
+
commandThroughSeverity(h, {
|
|
2881
|
+
kind: 'retarget',
|
|
2882
|
+
targets: responsiveFinalRetargets,
|
|
2883
|
+
transition: baseRetargetTransition,
|
|
2884
|
+
})
|
|
2885
|
+
}
|
|
2886
|
+
}
|
|
2887
|
+
if (scalarKeys.length > 0) {
|
|
2888
|
+
if (livePolicy.skipAnimations) {
|
|
2889
|
+
commandThroughSeverity(h, {
|
|
2890
|
+
kind: 'retarget',
|
|
2891
|
+
targets: changedScalars,
|
|
2892
|
+
transition: INSTANT_TRANSITION,
|
|
2893
|
+
})
|
|
2894
|
+
} else if (livePolicy.shouldReduceMotion) {
|
|
2895
|
+
const transformTargets: Record<string, number> = {}
|
|
2896
|
+
const nonTransformTargets: Record<string, number> = {}
|
|
2897
|
+
for (const key of scalarKeys) {
|
|
2898
|
+
if (TRANSFORM_KEY_SET.has(key)) transformTargets[key] = changedScalars[key]!
|
|
2899
|
+
else nonTransformTargets[key] = changedScalars[key]!
|
|
2900
|
+
}
|
|
2901
|
+
if (Object.keys(transformTargets).length > 0) {
|
|
2902
|
+
commandThroughSeverity(h, {
|
|
2903
|
+
kind: 'retarget',
|
|
2904
|
+
targets: transformTargets,
|
|
2905
|
+
transition: INSTANT_TRANSITION,
|
|
2906
|
+
})
|
|
2907
|
+
}
|
|
2908
|
+
if (Object.keys(nonTransformTargets).length > 0) {
|
|
2909
|
+
commandThroughSeverity(h, {
|
|
2910
|
+
kind: 'retarget',
|
|
2911
|
+
targets: nonTransformTargets,
|
|
2912
|
+
transition: baseRetargetTransition,
|
|
2913
|
+
})
|
|
2914
|
+
}
|
|
2915
|
+
} else {
|
|
2916
|
+
commandThroughSeverity(h, {
|
|
2917
|
+
kind: 'retarget',
|
|
2918
|
+
targets: changedScalars,
|
|
2919
|
+
transition: baseRetargetTransition,
|
|
2920
|
+
})
|
|
2921
|
+
}
|
|
2922
|
+
}
|
|
2923
|
+
if (Object.keys(colorTargets).length > 0 || Object.keys(discreteTargets).length > 0) {
|
|
2924
|
+
for (const { key, declaredTarget, sequence } of pendingColorSeeds) {
|
|
2925
|
+
seed(key, declaredTarget, sequence)
|
|
2926
|
+
}
|
|
2927
|
+
for (const { key, declaredTarget, pair } of pendingDiscreteSeeds) {
|
|
2928
|
+
seedDiscrete(key, declaredTarget, pair)
|
|
2929
|
+
}
|
|
2930
|
+
// REQ-API-035: skipAnimations/isStatic force INSTANT on every key — the color start's
|
|
2931
|
+
// command-level transition is the fallback when a target omits per-prop (drivers read
|
|
2932
|
+
// target.transition ?? command.transition). Per-prop already covers array colors via
|
|
2933
|
+
// perPropTransition above; under skipAnimations the command slot is INSTANT too so no
|
|
2934
|
+
// color key can fall back to a timed tween (review r2 major 4).
|
|
2935
|
+
commandThroughSeverity(h, {
|
|
2936
|
+
kind: 'start',
|
|
2937
|
+
targets: { ...colorTargets, ...discreteTargets },
|
|
2938
|
+
transition: livePolicy.skipAnimations ? INSTANT_TRANSITION : (transition ?? {}),
|
|
2939
|
+
})
|
|
2940
|
+
}
|
|
2941
|
+
if (next.removalValues !== undefined) {
|
|
2942
|
+
for (const key of changedArrayKeys) {
|
|
2943
|
+
const target = resolved[key]
|
|
2944
|
+
if (Array.isArray(target) && target[0] === null) handedOffRemovalKeys.add(key)
|
|
2945
|
+
}
|
|
2946
|
+
for (const key of changedColorKeys) {
|
|
2947
|
+
const target = resolvedColors[key]
|
|
2948
|
+
if (target !== undefined && isColorArrayTarget(target) && target[0] === null) {
|
|
2949
|
+
handedOffRemovalKeys.add(key)
|
|
2950
|
+
}
|
|
2951
|
+
}
|
|
2952
|
+
}
|
|
2953
|
+
// The diff base records COMMANDED values only: a deferred pair (review 73044cc0e1bd) is
|
|
2954
|
+
// not applied yet — the flush publishes its `current` when it commands the pair.
|
|
2955
|
+
// Resting-form membership (review 1578a42974bc): a key rests at auto only when its FINAL
|
|
2956
|
+
// authored keyframe is auto — an auto MIDDLE keyframe is a measurement episode, not the
|
|
2957
|
+
// resting form. remeasureAutoRests re-seeds the released keys on content drift so the next
|
|
2958
|
+
// edge originates from the drifted content, never a stale measure. A commanded key leaves
|
|
2959
|
+
// the initial-only relative-rest lane too (review 7cec8ad963d8): its owning animate target
|
|
2960
|
+
// re-resolves it through the ordinary update lane.
|
|
2961
|
+
for (const key of Object.keys(startTargets)) {
|
|
2962
|
+
if (restsAtAutoForm((compatAnimate as Record<string, unknown>)[key])) {
|
|
2963
|
+
autoRestKeys.add(key)
|
|
2964
|
+
} else {
|
|
2965
|
+
autoRestKeys.delete(key)
|
|
2966
|
+
}
|
|
2967
|
+
relativeRestForms.delete(key)
|
|
2968
|
+
// Non-array authority took the key (review b327265f3dc5): a later identical array is a
|
|
2969
|
+
// NEW authored episode (restart), never a context-only re-resolution of the old one.
|
|
2970
|
+
if (!Array.isArray((compatAnimate as Record<string, unknown>)[key])) {
|
|
2971
|
+
lastCommandedRawKeyframes.delete(key)
|
|
2972
|
+
}
|
|
2973
|
+
}
|
|
2974
|
+
for (const key of scalarKeys) {
|
|
2975
|
+
if (restsAtAutoForm((compatAnimate as Record<string, unknown>)[key])) {
|
|
2976
|
+
autoRestKeys.add(key)
|
|
2977
|
+
} else {
|
|
2978
|
+
autoRestKeys.delete(key)
|
|
2979
|
+
}
|
|
2980
|
+
relativeRestForms.delete(key)
|
|
2981
|
+
lastCommandedRawKeyframes.delete(key)
|
|
2982
|
+
}
|
|
2983
|
+
const nextCurrent = { ...current }
|
|
2984
|
+
for (const key of Object.keys(resolved)) {
|
|
2985
|
+
if (!deferredPairKeys.has(key)) nextCurrent[key] = resolved[key]!
|
|
2986
|
+
}
|
|
2987
|
+
current = nextCurrent
|
|
2988
|
+
return true
|
|
2989
|
+
},
|
|
2990
|
+
|
|
2991
|
+
applyTransitionEnd(target) {
|
|
2992
|
+
const h = requireHandle()
|
|
2993
|
+
// BUILD every jump first (the major-36 atomicity shape): a refused key mutates nothing.
|
|
2994
|
+
const numericJumps: Record<string, number> = {}
|
|
2995
|
+
const discreteJumps: { key: string; keyword: string }[] = []
|
|
2996
|
+
const colorJumps: { key: string; color: RGBA }[] = []
|
|
2997
|
+
for (const key of Object.keys(target)) {
|
|
2998
|
+
if (!registeredKeys.has(key)) {
|
|
2999
|
+
throw new Error(
|
|
3000
|
+
`T23: cannot apply transitionEnd '${key}': it was not mounted on this element ` +
|
|
3001
|
+
'(the animated key set is fixed at mount, REQ-API-002/012).',
|
|
3002
|
+
)
|
|
3003
|
+
}
|
|
3004
|
+
const value = target[key]
|
|
3005
|
+
if (isDiscreteKey(key)) {
|
|
3006
|
+
discreteJumps.push({ key, keyword: discreteValue(key, value) })
|
|
3007
|
+
} else if (isColorKey(key)) {
|
|
3008
|
+
colorJumps.push({ key, color: colorValue(key, value) })
|
|
3009
|
+
} else {
|
|
3010
|
+
const resolved = numericTargetValue(key, value, lengthLayoutOf())
|
|
3011
|
+
if (typeof resolved !== 'number') {
|
|
3012
|
+
throw new Error(
|
|
3013
|
+
`T23: transitionEnd '${key}' must jump to a single numeric value ` +
|
|
3014
|
+
'(keyframe arrays cannot jump).',
|
|
3015
|
+
)
|
|
3016
|
+
}
|
|
3017
|
+
numericJumps[key] = resolved
|
|
3018
|
+
}
|
|
3019
|
+
}
|
|
3020
|
+
// COMMIT: progress lanes re-seed settled (constant projection at the jumped value) and
|
|
3021
|
+
// start INSTANT at PROGRESS_END; numerics ride one INSTANT retarget. Diff bases move so
|
|
3022
|
+
// a later identical animate is a no-op.
|
|
3023
|
+
const progressTargets: Record<string, PropTarget> = {}
|
|
3024
|
+
for (const { key, keyword } of discreteJumps) {
|
|
3025
|
+
seedDiscrete(key, keyword, [keyword, keyword])
|
|
3026
|
+
progressTargets[key] = { to: PROGRESS_END, from: PROGRESS_END }
|
|
3027
|
+
}
|
|
3028
|
+
for (const { key, color } of colorJumps) {
|
|
3029
|
+
seed(key, color, [color, color])
|
|
3030
|
+
progressTargets[key] = { to: PROGRESS_END, from: PROGRESS_END }
|
|
3031
|
+
}
|
|
3032
|
+
if (Object.keys(progressTargets).length > 0) {
|
|
3033
|
+
commandThroughSeverity(h, {
|
|
3034
|
+
kind: 'start',
|
|
3035
|
+
targets: progressTargets,
|
|
3036
|
+
transition: INSTANT_TRANSITION,
|
|
3037
|
+
})
|
|
3038
|
+
}
|
|
3039
|
+
if (Object.keys(numericJumps).length > 0) {
|
|
3040
|
+
commandThroughSeverity(h, {
|
|
3041
|
+
kind: 'retarget',
|
|
3042
|
+
targets: numericJumps,
|
|
3043
|
+
transition: INSTANT_TRANSITION,
|
|
3044
|
+
})
|
|
3045
|
+
current = { ...current, ...numericJumps }
|
|
3046
|
+
}
|
|
3047
|
+
},
|
|
3048
|
+
|
|
3049
|
+
currentColor(key: string): string {
|
|
3050
|
+
// The deterministic observable of L1 §semantics 3: the SAME projectColorSequence seam the
|
|
3051
|
+
// animated style binds, evaluated at the driver's live progress (major 35 — projects THROUGH
|
|
3052
|
+
// the sequence; a scalar color is the 2-element endpoint case).
|
|
3053
|
+
const h = requireHandle()
|
|
3054
|
+
const c = colors[key]
|
|
3055
|
+
if (c === undefined) {
|
|
3056
|
+
throw new Error(
|
|
3057
|
+
`L1: '${key}' is not a color key mounted on this element (specs/L1-BUILD-PACKET.md).`,
|
|
3058
|
+
)
|
|
3059
|
+
}
|
|
3060
|
+
const progress = binding.driver.committed(h)[key]
|
|
3061
|
+
if (progress === undefined) {
|
|
3062
|
+
throw new Error(`L1: color '${key}' has no registered driver prop on this element.`)
|
|
3063
|
+
}
|
|
3064
|
+
return projectColorSequence(c, progress)
|
|
3065
|
+
},
|
|
3066
|
+
|
|
3067
|
+
currentDiscrete(key: string): string {
|
|
3068
|
+
// The discrete deterministic observable (T23 B2b): the SAME projectDiscreteStep seam the
|
|
3069
|
+
// animated style binds, evaluated at the driver's live progress.
|
|
3070
|
+
const h = requireHandle()
|
|
3071
|
+
const pair = discretes[key]
|
|
3072
|
+
if (pair === undefined) {
|
|
3073
|
+
throw new Error(`T23: '${key}' is not a discrete key mounted on this element.`)
|
|
3074
|
+
}
|
|
3075
|
+
const progress = binding.driver.committed(h)[key]
|
|
3076
|
+
if (progress === undefined) {
|
|
3077
|
+
throw new Error(`T23: discrete '${key}' has no registered driver prop on this element.`)
|
|
3078
|
+
}
|
|
3079
|
+
return projectDiscreteStep(pair[0], pair[1], progress)
|
|
3080
|
+
},
|
|
3081
|
+
|
|
3082
|
+
projectLatestValues(raw) {
|
|
3083
|
+
// T23 C: the onUpdate projection — the driver marshal crosses the RAW committed record
|
|
3084
|
+
// (numerics as-is; color/discrete lanes as [0,100] progress). Project each progress lane
|
|
3085
|
+
// through the SAME seam its observable binds (currentColor / currentDiscrete — one truth
|
|
3086
|
+
// per family); numeric keys pass through untouched. Pure JS, no UI crossing: the record
|
|
3087
|
+
// already crossed with the marshal.
|
|
3088
|
+
const out: Record<string, string | number> = {}
|
|
3089
|
+
for (const key of Object.keys(raw)) {
|
|
3090
|
+
const value = raw[key]!
|
|
3091
|
+
const colorSequence = colors[key]
|
|
3092
|
+
if (colorSequence !== undefined) {
|
|
3093
|
+
out[key] = projectColorSequence(colorSequence, value)
|
|
3094
|
+
continue
|
|
3095
|
+
}
|
|
3096
|
+
const pair = discretes[key]
|
|
3097
|
+
if (pair !== undefined) {
|
|
3098
|
+
out[key] = projectDiscreteStep(pair[0], pair[1], value)
|
|
3099
|
+
continue
|
|
3100
|
+
}
|
|
3101
|
+
out[key] = value
|
|
3102
|
+
}
|
|
3103
|
+
return out
|
|
3104
|
+
},
|
|
3105
|
+
|
|
3106
|
+
committedValues(keys) {
|
|
3107
|
+
const committed = binding.driver.committed(requireHandle())
|
|
3108
|
+
const snapshot: Record<string, ResolvedValue> = {}
|
|
3109
|
+
for (const key of keys) {
|
|
3110
|
+
if (!registeredKeys.has(key)) {
|
|
3111
|
+
throw new Error(
|
|
3112
|
+
`cannot snapshot committed '${key}': it was not registered on this MotionView controller.`,
|
|
3113
|
+
)
|
|
3114
|
+
}
|
|
3115
|
+
const color = colors[key]
|
|
3116
|
+
if (color !== undefined) {
|
|
3117
|
+
const progress = committed[key]
|
|
3118
|
+
if (progress === undefined) {
|
|
3119
|
+
throw new Error(`L1: color '${key}' has no registered driver prop on this element.`)
|
|
3120
|
+
}
|
|
3121
|
+
snapshot[key] = projectColorSequence(color, progress)
|
|
3122
|
+
continue
|
|
3123
|
+
}
|
|
3124
|
+
const pair = discretes[key]
|
|
3125
|
+
if (pair !== undefined) {
|
|
3126
|
+
const progress = committed[key]
|
|
3127
|
+
if (progress === undefined) {
|
|
3128
|
+
throw new Error(`T23: discrete '${key}' has no registered driver prop on this element.`)
|
|
3129
|
+
}
|
|
3130
|
+
snapshot[key] = projectDiscreteStep(pair[0], pair[1], progress)
|
|
3131
|
+
continue
|
|
3132
|
+
}
|
|
3133
|
+
const value = committed[key]
|
|
3134
|
+
if (value === undefined) {
|
|
3135
|
+
throw new Error(
|
|
3136
|
+
`cannot snapshot committed '${key}': it is registered on the MotionView controller ` +
|
|
3137
|
+
'but absent from the driver.',
|
|
3138
|
+
)
|
|
3139
|
+
}
|
|
3140
|
+
snapshot[key] = value
|
|
3141
|
+
}
|
|
3142
|
+
return Object.freeze(snapshot)
|
|
3143
|
+
},
|
|
3144
|
+
|
|
3145
|
+
variantValueState() {
|
|
3146
|
+
if (unmounted) {
|
|
3147
|
+
throw new Error('MotionView controller cannot read variant value state after unmount().')
|
|
3148
|
+
}
|
|
3149
|
+
const h = requireHandle()
|
|
3150
|
+
const current: Record<string, ResolvedValue> = {}
|
|
3151
|
+
const velocity: Record<string, number> = {}
|
|
3152
|
+
for (const key of registeredKeys) {
|
|
3153
|
+
const live = binding.driver.liveFor(h, key)
|
|
3154
|
+
const color = colors[key]
|
|
3155
|
+
if (color !== undefined) {
|
|
3156
|
+
current[key] = projectColorSequence(color, live.value)
|
|
3157
|
+
velocity[key] = 0
|
|
3158
|
+
continue
|
|
3159
|
+
}
|
|
3160
|
+
const pair = discretes[key]
|
|
3161
|
+
if (pair !== undefined) {
|
|
3162
|
+
current[key] = projectDiscreteStep(pair[0], pair[1], live.value)
|
|
3163
|
+
velocity[key] = 0
|
|
3164
|
+
continue
|
|
3165
|
+
}
|
|
3166
|
+
current[key] = live.value
|
|
3167
|
+
velocity[key] = live.velocity
|
|
3168
|
+
}
|
|
3169
|
+
return { current: Object.freeze(current), velocity: Object.freeze(velocity) }
|
|
3170
|
+
},
|
|
3171
|
+
|
|
3172
|
+
unmount() {
|
|
3173
|
+
binding.driver.setActive(requireHandle(), false)
|
|
3174
|
+
unmounted = true
|
|
3175
|
+
},
|
|
3176
|
+
}
|
|
3177
|
+
}
|
|
3178
|
+
|
|
3179
|
+
// Re-exported so the checks screen asserts the exact error class the pipeline throws.
|
|
3180
|
+
export { InvalidTargetError }
|