@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,657 @@
|
|
|
1
|
+
// Style-bound public MotionValues (REQ-API-026 / REQ-DRIVER-029): a bound value's DIRECT writes
|
|
2
|
+
// commit through the driver's binding lane — one crossing per set(), the same event-shaped
|
|
3
|
+
// cadence gesture writes ride, never a frame loop. Unlike a gesture write, a direct set settles
|
|
4
|
+
// immediately and cannot manufacture a finger hold. Review r1 major 1 law: a bound
|
|
5
|
+
// value REFUSES animation-driven traffic — a JS-side animation would cross the bridge every
|
|
6
|
+
// frame (the stall the Elsewhere stress toggle exists to expose), so `animationStart` on a
|
|
7
|
+
// bound value fails under the severity law naming the driver-derived-channel successor rung.
|
|
8
|
+
// Two string-binding classes join the numeric one (REQ-API-026): U5's rgba class (COLOR_KEY_NAMES,
|
|
9
|
+
// mappedKeys.ts) binds a MotionValue<string> and publishes through the settled-color pattern —
|
|
10
|
+
// a fresh `[c, c]` endpoint sequence at PROGRESS_END through the controller's onColorEndpoints
|
|
11
|
+
// port (packet F3), the same delivery static authored colors use; U5 W3's `transform` key binds a
|
|
12
|
+
// MotionValue<string> parsed per set() with the pinned transform-function grammar
|
|
13
|
+
// (transformStringBinding.ts) and written through the SAME numeric retarget lane as ONE multi-key
|
|
14
|
+
// map (parsed components at their values, absent components reset to their pin defaults — the
|
|
15
|
+
// pin's imperative `element.style.transform = str` replaces the whole transform,
|
|
16
|
+
// motion-dom build-styles.ts:52-66). A bound color is validated at
|
|
17
|
+
// split AND per set() (core's strict isColor + parseColor validator, REQ-VALUETYPE-008
|
|
18
|
+
// lineage): the pin binds any string verbatim (motion@12.42.2 scrape-motion-values.ts:6-29),
|
|
19
|
+
// native keeps its standing loud policy. A bound transform string is validated the same way —
|
|
20
|
+
// the pinned-grammar parse refuses loud naming the key and the unsupported form. General
|
|
21
|
+
// string/unit binding beyond the rgba class and `transform` stays deferred with the same loud,
|
|
22
|
+
// named failure.
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
describeValue,
|
|
26
|
+
isColor,
|
|
27
|
+
parseColor,
|
|
28
|
+
type ElementHandle,
|
|
29
|
+
type MotionValue,
|
|
30
|
+
type RGBA,
|
|
31
|
+
} from '@unrulysystems/native-motion-core'
|
|
32
|
+
import { isDirectPublicNumericMotionValue } from '@unrulysystems/native-motion-core/internal-driver'
|
|
33
|
+
import type { WorkletDriverBinding } from '../driver/workletDriver'
|
|
34
|
+
import {
|
|
35
|
+
COLOR_KEY_NAMES,
|
|
36
|
+
NUMERIC_STYLE_KEY_NAMES,
|
|
37
|
+
TRANSFORM_KEY_NAMES,
|
|
38
|
+
TRANSFORM_STRING_KEY_NAME,
|
|
39
|
+
} from './mappedKeys'
|
|
40
|
+
import { NATIVE_TRANSFORM_ORDER } from './transformOrder'
|
|
41
|
+
import {
|
|
42
|
+
isNativeTransformComponentKey,
|
|
43
|
+
parseTransformStringComponents,
|
|
44
|
+
transformStringWriteMap,
|
|
45
|
+
} from './transformStringBinding'
|
|
46
|
+
|
|
47
|
+
// The rgba-class binding set (U5): the literal class array as a membership probe. mappedKeys is
|
|
48
|
+
// data-only (no RN imports), and its registry cross-check test binds the class to the shipped
|
|
49
|
+
// color keys — the same set the controller's isColorKey classifies through the registry.
|
|
50
|
+
const COLOR_BINDABLE_KEYS: ReadonlySet<string> = new Set<string>(COLOR_KEY_NAMES)
|
|
51
|
+
const IDENTITY_CHANNEL_NUMERIC_KEYS: ReadonlySet<string> = new Set<string>([
|
|
52
|
+
...TRANSFORM_KEY_NAMES,
|
|
53
|
+
...NUMERIC_STYLE_KEY_NAMES,
|
|
54
|
+
])
|
|
55
|
+
|
|
56
|
+
/** A bound entry's value: numeric on the numeric-natured keys, a color string on the rgba class. */
|
|
57
|
+
export type BoundStyleValue = MotionValue<number> | MotionValue<string>
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Shape hint shared by full public MotionValues and partial duck-types. Objects with get +
|
|
61
|
+
* subscribe but a missing runtime surface must never be treated as ordinary statics (they would
|
|
62
|
+
* reach the RN host bag) or as full MotionValues (attach would TypeError) — family-5 major
|
|
63
|
+
* 285dacf76bfe.
|
|
64
|
+
*/
|
|
65
|
+
export function hasMotionValueShapeHints(candidate: unknown): boolean {
|
|
66
|
+
return (
|
|
67
|
+
typeof candidate === 'object' &&
|
|
68
|
+
candidate !== null &&
|
|
69
|
+
typeof (candidate as { get?: unknown }).get === 'function' &&
|
|
70
|
+
typeof (candidate as { subscribe?: unknown }).subscribe === 'function'
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Exact public MotionValue duck-type used by every style / transform binding authority.
|
|
76
|
+
* Requires the full runtime surface the binding attach path immediately invokes (get/set/jump/
|
|
77
|
+
* subscribe/getVelocity/on/stop/isAnimating/destroy). Partial objects are refused separately.
|
|
78
|
+
*/
|
|
79
|
+
export function isMotionValue(candidate: unknown): candidate is MotionValue<unknown> {
|
|
80
|
+
if (!hasMotionValueShapeHints(candidate)) return false
|
|
81
|
+
const value = candidate as MotionValue<unknown>
|
|
82
|
+
return (
|
|
83
|
+
typeof value.set === 'function' &&
|
|
84
|
+
typeof value.jump === 'function' &&
|
|
85
|
+
typeof value.getVelocity === 'function' &&
|
|
86
|
+
typeof value.on === 'function' &&
|
|
87
|
+
typeof value.stop === 'function' &&
|
|
88
|
+
typeof value.isAnimating === 'function' &&
|
|
89
|
+
typeof value.destroy === 'function'
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Partial duck-type: MotionValue-shaped hints without the full public surface. */
|
|
94
|
+
export function isNearMotionValue(candidate: unknown): boolean {
|
|
95
|
+
return hasMotionValueShapeHints(candidate) && !isMotionValue(candidate)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface StyleValueGate {
|
|
99
|
+
readonly severity: 'development' | 'production'
|
|
100
|
+
readonly report: (error: Error) => void
|
|
101
|
+
/**
|
|
102
|
+
* Public host identity for errors emitted by the shared style-binding lanes.
|
|
103
|
+
* Required — a silent Motion.View default would launder Text refusals as View (R9 M2 r15).
|
|
104
|
+
*/
|
|
105
|
+
readonly componentId: '<Motion.View>' | '<Motion.Text>' | '<Motion.Image>'
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const THROW_GATE: StyleValueGate = {
|
|
109
|
+
severity: 'development',
|
|
110
|
+
report: () => {},
|
|
111
|
+
componentId: '<Motion.View>',
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const componentPrefix = (gate: StyleValueGate): string => `${gate.componentId}: `
|
|
115
|
+
|
|
116
|
+
export interface SplitBoundStyle {
|
|
117
|
+
/** Style entries whose value is a public MotionValue, keyed by style key. */
|
|
118
|
+
readonly bound: Record<string, BoundStyleValue>
|
|
119
|
+
/** The one scalar snapshot captured while admitting each bound entry. */
|
|
120
|
+
readonly resolved: Record<string, number | string>
|
|
121
|
+
/** The remaining plain entries, untouched (the existing static-style seeding path). */
|
|
122
|
+
readonly statics: Record<string, unknown>
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const motionViewAuthoredStyleMountPreparationBrand: unique symbol = Symbol(
|
|
126
|
+
'native-motion.motion-view-authored-style-mount-preparation',
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Opaque result of the authored-style split MotionView runs during mount. Identity-channel
|
|
131
|
+
* adapters consume this object instead of accepting a second caller-authored key/value map, so
|
|
132
|
+
* the value identity registered with the driver is necessarily the one extracted from `style`.
|
|
133
|
+
*/
|
|
134
|
+
export interface MotionViewAuthoredStyleMountPreparation {
|
|
135
|
+
readonly [motionViewAuthoredStyleMountPreparationBrand]: true
|
|
136
|
+
readonly boundStyleValues: Record<string, BoundStyleValue>
|
|
137
|
+
readonly resolvedBoundStyleValues: Record<string, number | string>
|
|
138
|
+
readonly staticStyleValues: Record<string, unknown>
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The exact native identity-channel admission class. The caller supplies the already captured
|
|
143
|
+
* scalar so this predicate never performs a collector-visible public read.
|
|
144
|
+
*/
|
|
145
|
+
export function isIdentityChannelEligibleBoundStyleValue(
|
|
146
|
+
key: string,
|
|
147
|
+
value: MotionValue<unknown>,
|
|
148
|
+
resolvedCurrentValue: unknown,
|
|
149
|
+
): value is MotionValue<number> {
|
|
150
|
+
return (
|
|
151
|
+
IDENTITY_CHANNEL_NUMERIC_KEYS.has(key) &&
|
|
152
|
+
!value.isAnimating() &&
|
|
153
|
+
isDirectPublicNumericMotionValue(value, resolvedCurrentValue)
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface IdentityChannelEligibleBoundStyleSnapshot {
|
|
158
|
+
readonly keys: readonly string[]
|
|
159
|
+
readonly resolvedStyleValues: Readonly<Record<string, number>>
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Selects exact eligible bindings from the mount snapshot without re-reading any MotionValue. */
|
|
163
|
+
export function identityChannelEligibleBoundStyleSnapshot(
|
|
164
|
+
preparation: MotionViewAuthoredStyleMountPreparation,
|
|
165
|
+
): IdentityChannelEligibleBoundStyleSnapshot {
|
|
166
|
+
const keys: string[] = []
|
|
167
|
+
const resolvedStyleValues: Record<string, number> = {}
|
|
168
|
+
for (const [key, value] of Object.entries(preparation.boundStyleValues)) {
|
|
169
|
+
const resolved = preparation.resolvedBoundStyleValues[key]
|
|
170
|
+
if (!isIdentityChannelEligibleBoundStyleValue(key, value, resolved)) continue
|
|
171
|
+
if (typeof resolved !== 'number') continue
|
|
172
|
+
keys.push(key)
|
|
173
|
+
resolvedStyleValues[key] = resolved
|
|
174
|
+
}
|
|
175
|
+
return { keys, resolvedStyleValues }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// The bound-color verdict (U5): a color-bound value must be a string core's strict color
|
|
179
|
+
// validator accepts — anything else refuses loud per the severity law, naming the key (the
|
|
180
|
+
// controller's color lane treats an unparseable color target the same way,
|
|
181
|
+
// motionViewController.ts:1075-1087). The barrel-legal pair does the validation: `isColor` is the
|
|
182
|
+
// TOTAL shape gate (named/hex/rgb/hsl), `parseColor` the strict validator — together they are
|
|
183
|
+
// exactly core's parseColorVerdict, whose verdict form is core-internal by the ratified
|
|
184
|
+
// value-types barrel law (value-types/index.ts). Returns the parsed RGBA, or null after a
|
|
185
|
+
// production report (the write is refused; the host keeps the last good value).
|
|
186
|
+
function boundColorValue(key: string, value: unknown, gate: StyleValueGate): RGBA | null {
|
|
187
|
+
let parsed: RGBA | null = null
|
|
188
|
+
let reason: string | null = null
|
|
189
|
+
if (typeof value === 'string') {
|
|
190
|
+
if (!isColor(value)) {
|
|
191
|
+
reason = `Cannot parse ${JSON.stringify(value)} as a color`
|
|
192
|
+
} else {
|
|
193
|
+
try {
|
|
194
|
+
parsed = parseColor(value)
|
|
195
|
+
} catch (error) {
|
|
196
|
+
// The shape gate passed, so a throw here is the strict validator's OWN refusal (a plain
|
|
197
|
+
// Error carrying the refusal message — core's verdict chain never throws anything else).
|
|
198
|
+
// Anything else is a dependency fault and propagates unmasked (M3 r7/r8's no-catch law).
|
|
199
|
+
if (error instanceof Error && error.constructor === Error) reason = error.message
|
|
200
|
+
else throw error
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (parsed !== null) return parsed
|
|
205
|
+
const error = new Error(
|
|
206
|
+
`${componentPrefix(gate)}style-bound MotionValue on color key '${key}' holds ${describeValue(value)}; ` +
|
|
207
|
+
'a bound color must be a color string (hex/rgb/hsl/named, REQ-API-026)' +
|
|
208
|
+
`${reason !== null ? ` — ${reason}` : ''}.`,
|
|
209
|
+
)
|
|
210
|
+
if (gate.severity === 'development') throw error
|
|
211
|
+
gate.report(error)
|
|
212
|
+
return null
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// The bound-transform verdict (U5 W3): a transform-bound value must be a string the pinned-grammar
|
|
216
|
+
// parser accepts (transformStringBinding.ts) — anything else refuses loud per the severity law,
|
|
217
|
+
// naming the key and the unsupported form (the pin hands the string to the browser, which
|
|
218
|
+
// silently drops invalid CSS; native keeps its standing loud policy — declared refuse-class
|
|
219
|
+
// debt, REQ-API-026). Returns the FULL 11-component write map (parsed values; absent components
|
|
220
|
+
// reset to their pin defaults — the pin's imperative replace drops them), or null after a
|
|
221
|
+
// production report (the write is refused; the host keeps the last good components).
|
|
222
|
+
function boundTransformWriteMap(
|
|
223
|
+
value: unknown,
|
|
224
|
+
gate: StyleValueGate,
|
|
225
|
+
): Record<string, number> | null {
|
|
226
|
+
let map: Record<string, number> | null = null
|
|
227
|
+
let reason: string | null = null
|
|
228
|
+
if (typeof value === 'string') {
|
|
229
|
+
try {
|
|
230
|
+
map = transformStringWriteMap(parseTransformStringComponents(value))
|
|
231
|
+
} catch (error) {
|
|
232
|
+
// The parser throws only plain Errors carrying the refusal reason; anything else is a
|
|
233
|
+
// dependency fault and propagates unmasked (M3 r7/r8's no-catch law).
|
|
234
|
+
if (error instanceof Error && error.constructor === Error) reason = error.message
|
|
235
|
+
else throw error
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (map !== null) return map
|
|
239
|
+
const error = new Error(
|
|
240
|
+
`${componentPrefix(gate)}style-bound MotionValue on 'transform' holds ${describeValue(value)}; ` +
|
|
241
|
+
'a bound transform must be a transform-function string in the pinned grammar (the pin ' +
|
|
242
|
+
'buildTransform emit set plus translate/translate3d, REQ-API-026, U5 W3)' +
|
|
243
|
+
`${reason !== null ? ` — ${reason}` : ''}.`,
|
|
244
|
+
)
|
|
245
|
+
if (gate.severity === 'development') throw error
|
|
246
|
+
gate.report(error)
|
|
247
|
+
return null
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// The near-MotionValue partial duck-type refusal — identical at split and reconcile: never a
|
|
251
|
+
// static host residual, never an attach TypeError.
|
|
252
|
+
function refuseNearMotionValue(key: string, gate: StyleValueGate): void {
|
|
253
|
+
const error = new Error(
|
|
254
|
+
`${componentPrefix(gate)}style value on '${key}' is a partial MotionValue duck-type ` +
|
|
255
|
+
`(missing full public surface set/jump/on/stop/isAnimating/destroy/getVelocity); ` +
|
|
256
|
+
'refused under the severity law — supply a real MotionValue or a plain static.',
|
|
257
|
+
)
|
|
258
|
+
if (gate.severity === 'development') throw error
|
|
259
|
+
gate.report(error)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Per-entry VALUE validation against the key's binding class (REQ-API-026): numeric-natured keys
|
|
263
|
+
// take a number-holding MotionValue; the rgba class takes a string-holding MotionValue whose
|
|
264
|
+
// current value parses as a color; `transform` takes a string-holding MotionValue whose current
|
|
265
|
+
// value parses with the pinned transform grammar. Development throws; production reports and the
|
|
266
|
+
// entry is refused. Used where a binding is ADMITTED (the mount split, a new/swapped source at
|
|
267
|
+
// reconcile) — never for an already-attached identity, whose per-write lane owns value validity.
|
|
268
|
+
type BoundEntryResolution =
|
|
269
|
+
| { readonly accepted: true; readonly current: number | string }
|
|
270
|
+
| { readonly accepted: false }
|
|
271
|
+
|
|
272
|
+
function resolveBoundEntry(
|
|
273
|
+
key: string,
|
|
274
|
+
value: MotionValue<unknown>,
|
|
275
|
+
gate: StyleValueGate,
|
|
276
|
+
): BoundEntryResolution {
|
|
277
|
+
const current = value.get()
|
|
278
|
+
if (key === TRANSFORM_STRING_KEY_NAME) {
|
|
279
|
+
// U5 W3: `transform` binds a MotionValue<string> in the pinned grammar — a numeric MV or an
|
|
280
|
+
// out-of-grammar string refuses here (never the unmapped-key gate downstream).
|
|
281
|
+
return boundTransformWriteMap(current, gate) === null
|
|
282
|
+
? { accepted: false }
|
|
283
|
+
: { accepted: true, current: current as string }
|
|
284
|
+
}
|
|
285
|
+
if (COLOR_BINDABLE_KEYS.has(key)) {
|
|
286
|
+
return boundColorValue(key, current, gate) === null
|
|
287
|
+
? { accepted: false }
|
|
288
|
+
: { accepted: true, current: current as string }
|
|
289
|
+
}
|
|
290
|
+
if (typeof current !== 'number') {
|
|
291
|
+
const error = new Error(
|
|
292
|
+
`${componentPrefix(gate)}R4 scope: style-bound MotionValue on '${key}' holds ${describeValue(current)}; ` +
|
|
293
|
+
'numeric keys bind only numeric MotionValues — the rgba class ' +
|
|
294
|
+
'(backgroundColor/color/borderColor) and `transform` bind MotionValue<string> ' +
|
|
295
|
+
'(REQ-API-026, U5); ' +
|
|
296
|
+
'general string/unit binding stays deferred to the value-breadth rung.',
|
|
297
|
+
)
|
|
298
|
+
if (gate.severity === 'development') throw error
|
|
299
|
+
gate.report(error) // production: refuse the binding, mount the rest
|
|
300
|
+
return { accepted: false }
|
|
301
|
+
}
|
|
302
|
+
return { accepted: true, current }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Splits bound entries out of a flattened style. A bound value outside its key's class fails
|
|
306
|
+
// under the severity law (REQ-API-014 lineage): development throws; production reports and
|
|
307
|
+
// REFUSES the binding (the key falls out entirely — never a garbage host write). The classes
|
|
308
|
+
// (REQ-API-026): numeric-natured keys take a number-holding MotionValue; the rgba class takes a
|
|
309
|
+
// string-holding MotionValue whose current value parses as a color; `transform` takes a
|
|
310
|
+
// string-holding MotionValue whose current value parses with the pinned transform grammar.
|
|
311
|
+
// Near-MotionValue partial
|
|
312
|
+
// duck-types are the same severity refusal — never static host residual, never attach TypeError.
|
|
313
|
+
export function splitBoundStyleValues(
|
|
314
|
+
flattened: Record<string, unknown>,
|
|
315
|
+
gate: StyleValueGate = THROW_GATE,
|
|
316
|
+
): SplitBoundStyle {
|
|
317
|
+
const bound: Record<string, BoundStyleValue> = {}
|
|
318
|
+
const resolved: Record<string, number | string> = {}
|
|
319
|
+
const statics: Record<string, unknown> = {}
|
|
320
|
+
for (const [key, value] of Object.entries(flattened)) {
|
|
321
|
+
if (isNearMotionValue(value)) {
|
|
322
|
+
refuseNearMotionValue(key, gate)
|
|
323
|
+
continue
|
|
324
|
+
}
|
|
325
|
+
if (!isMotionValue(value)) {
|
|
326
|
+
statics[key] = value
|
|
327
|
+
continue
|
|
328
|
+
}
|
|
329
|
+
const resolution = resolveBoundEntry(key, value, gate)
|
|
330
|
+
if (!resolution.accepted) continue
|
|
331
|
+
bound[key] = value as BoundStyleValue
|
|
332
|
+
resolved[key] = resolution.current
|
|
333
|
+
}
|
|
334
|
+
return { bound, resolved, statics }
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* The single authored-style entrance used by MotionView mount preparation and the exact-identity
|
|
339
|
+
* adapter. The wrapper-shaped argument keeps `style`—not a parallel `boundValues` fixture—as the
|
|
340
|
+
* source of truth while reusing the existing validation and severity law unchanged.
|
|
341
|
+
*/
|
|
342
|
+
export function prepareMotionViewAuthoredStyleForMount(
|
|
343
|
+
props: { readonly style: Record<string, unknown> },
|
|
344
|
+
gate: StyleValueGate = THROW_GATE,
|
|
345
|
+
): MotionViewAuthoredStyleMountPreparation {
|
|
346
|
+
const { bound, resolved, statics } = splitBoundStyleValues(props.style, gate)
|
|
347
|
+
return {
|
|
348
|
+
[motionViewAuthoredStyleMountPreparationBrand]: true,
|
|
349
|
+
boundStyleValues: bound,
|
|
350
|
+
resolvedBoundStyleValues: resolved,
|
|
351
|
+
staticStyleValues: statics,
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* The RENDER-reconcile counterpart to the mount split (U5 device-proof fix). The reconcile runs
|
|
357
|
+
* after EVERY commit and answers one question: which (key → MotionValue) bindings does the
|
|
358
|
+
* current style declare? It reconciles IDENTITIES, not values:
|
|
359
|
+
*
|
|
360
|
+
* - An identity-UNCHANGED entry (same source already attached) carries over untouched — even
|
|
361
|
+
* while the source holds a value the write lane just refused. Value validity is a per-WRITE
|
|
362
|
+
* concern: the write lane reported the refusal at set() time and kept the last good host
|
|
363
|
+
* value; re-validating here re-reports the same refusal on every render (a flood) and drops
|
|
364
|
+
* the key, detaching the subscription so a later VALID set() never reaches the host. The
|
|
365
|
+
* pin's posture: the refused write is dropped, the binding lives, the next valid set()
|
|
366
|
+
* applies.
|
|
367
|
+
* - A NEW or SWAPPED source is value-validated exactly like the mount split before it may bind
|
|
368
|
+
* (development throws; production reports once and the key stays unbound).
|
|
369
|
+
*/
|
|
370
|
+
export function reconcileBoundStyleValues(
|
|
371
|
+
flattened: Record<string, unknown>,
|
|
372
|
+
attached: Readonly<Record<string, BoundStyleValue>> | null,
|
|
373
|
+
gate: StyleValueGate = THROW_GATE,
|
|
374
|
+
): Record<string, BoundStyleValue> {
|
|
375
|
+
return reconcileBoundStyleValuesWithIdentityEligibility(flattened, attached, [], gate)
|
|
376
|
+
.boundStyleValues
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export interface BoundStyleIdentityReconciliation {
|
|
380
|
+
readonly boundStyleValues: Record<string, BoundStyleValue>
|
|
381
|
+
readonly identityEligibleBoundKeys: readonly string[]
|
|
382
|
+
/** One-read snapshots for new or swapped bindings; unchanged identities are intentionally absent. */
|
|
383
|
+
readonly resolvedReplacementStyleValues: Readonly<Record<string, number | string>>
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* MotionView's render reconciliation. Existing identities inherit their bind-time verdict; a new
|
|
388
|
+
* or swapped source is read once for ordinary binding validation and exact identity eligibility.
|
|
389
|
+
*/
|
|
390
|
+
export function reconcileBoundStyleValuesWithIdentityEligibility(
|
|
391
|
+
flattened: Record<string, unknown>,
|
|
392
|
+
attached: Readonly<Record<string, BoundStyleValue>> | null,
|
|
393
|
+
attachedIdentityEligibleBoundKeys: readonly string[],
|
|
394
|
+
gate: StyleValueGate = THROW_GATE,
|
|
395
|
+
): BoundStyleIdentityReconciliation {
|
|
396
|
+
const bound: Record<string, BoundStyleValue> = {}
|
|
397
|
+
const identityEligibleBoundKeys: string[] = []
|
|
398
|
+
const resolvedReplacementStyleValues: Record<string, number | string> = {}
|
|
399
|
+
for (const [key, value] of Object.entries(flattened)) {
|
|
400
|
+
if (isNearMotionValue(value)) {
|
|
401
|
+
refuseNearMotionValue(key, gate)
|
|
402
|
+
continue
|
|
403
|
+
}
|
|
404
|
+
if (!isMotionValue(value)) continue
|
|
405
|
+
if (attached?.[key] === value) {
|
|
406
|
+
bound[key] = value as BoundStyleValue
|
|
407
|
+
if (attachedIdentityEligibleBoundKeys.includes(key)) identityEligibleBoundKeys.push(key)
|
|
408
|
+
continue
|
|
409
|
+
}
|
|
410
|
+
const resolution = resolveBoundEntry(key, value, gate)
|
|
411
|
+
if (!resolution.accepted) continue
|
|
412
|
+
bound[key] = value as BoundStyleValue
|
|
413
|
+
resolvedReplacementStyleValues[key] = resolution.current
|
|
414
|
+
if (isIdentityChannelEligibleBoundStyleValue(key, value, resolution.current)) {
|
|
415
|
+
identityEligibleBoundKeys.push(key)
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return { boundStyleValues: bound, identityEligibleBoundKeys, resolvedReplacementStyleValues }
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* The UPDATE lane's XOR gate (review r8 major 270052714f55): the one-authority-per-key law
|
|
423
|
+
* holds through the whole lifecycle, so an animate target arriving on a currently-BOUND key is
|
|
424
|
+
* rejected exactly like the mount-time crossover (development throws; production reports and
|
|
425
|
+
* refuses the key while the rest of the update proceeds). U5 W3: a bound `transform` occupies
|
|
426
|
+
* the WHOLE transform component space (the pin's bound string replaces the component build
|
|
427
|
+
* entirely, motion-dom build-styles.ts:52-66), so an animate target on ANY transform shorthand
|
|
428
|
+
* while `transform` is bound is the same dual-authority violation.
|
|
429
|
+
*/
|
|
430
|
+
export function gateUpdateAgainstBindings<T extends Record<string, unknown>>(
|
|
431
|
+
gatedTarget: T,
|
|
432
|
+
boundKeys: readonly string[],
|
|
433
|
+
gate: StyleValueGate = THROW_GATE,
|
|
434
|
+
identityEligibleBoundKeys: readonly string[] = [],
|
|
435
|
+
): T {
|
|
436
|
+
const transformBound = boundKeys.includes(TRANSFORM_STRING_KEY_NAME)
|
|
437
|
+
for (const key of Object.keys(gatedTarget)) {
|
|
438
|
+
const boundHit = boundKeys.includes(key)
|
|
439
|
+
const transformHit = transformBound && isNativeTransformComponentKey(key)
|
|
440
|
+
if (!boundHit && !transformHit) continue
|
|
441
|
+
if (boundHit && identityEligibleBoundKeys.includes(key)) continue
|
|
442
|
+
const error = new Error(
|
|
443
|
+
boundHit
|
|
444
|
+
? `${componentPrefix(gate)}R4 scope: '${key}' is style-bound AND received an animate target — a key has ONE ` +
|
|
445
|
+
'reconciliation authority (REQ-DRIVER-029); drive it through the binding or the ' +
|
|
446
|
+
'target, never both.'
|
|
447
|
+
: `${componentPrefix(gate)}R4 scope: '${key}' received an animate target while 'transform' is style-bound — ` +
|
|
448
|
+
'a bound transform occupies the WHOLE transform component space, so one authority ' +
|
|
449
|
+
'covers every shorthand (REQ-DRIVER-029); drive it through the binding or the ' +
|
|
450
|
+
'target, never both.',
|
|
451
|
+
)
|
|
452
|
+
if (gate.severity === 'development') throw error
|
|
453
|
+
gate.report(error)
|
|
454
|
+
delete gatedTarget[key]
|
|
455
|
+
}
|
|
456
|
+
return gatedTarget
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Strip EVERY MotionValue-shaped entry from a flattened style (review r8 major 53d5f6c1f50e):
|
|
461
|
+
* the host must never receive a raw MotionValue object — including bindings the severity lane
|
|
462
|
+
* REFUSED (non-numeric on a numeric key, invalid color on an rgba key, unmapped, partial
|
|
463
|
+
* duck-types), which are absent from the accepted set but still present in the authored style.
|
|
464
|
+
* Shape hints alone (get+subscribe) are enough to strip; incomplete surfaces must not reach RN
|
|
465
|
+
* (family-5 major 285dacf76bfe).
|
|
466
|
+
*/
|
|
467
|
+
export function stripMotionValueEntries(
|
|
468
|
+
flattened: Record<string, unknown>,
|
|
469
|
+
): Record<string, unknown> {
|
|
470
|
+
const stripped: Record<string, unknown> = {}
|
|
471
|
+
for (const [key, value] of Object.entries(flattened)) {
|
|
472
|
+
if (!hasMotionValueShapeHints(value)) stripped[key] = value
|
|
473
|
+
}
|
|
474
|
+
return stripped
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* The per-render bound reconcile gate (r15 major 2f0c634bd8e1): the bindable set is the
|
|
479
|
+
* BOUND-AT-MOUNT key set — never the full mounted key set. A key mounted declaratively already
|
|
480
|
+
* has a driver registration; accepting a later binding on it would put two authorities on one
|
|
481
|
+
* key (the old registration and the new binding coexist). Identity swaps/removals within the
|
|
482
|
+
* mounted bound set pass; a key currently targeted by animate is the r8 crossover refusal.
|
|
483
|
+
* Returns the accepted bound map (production drops refused keys; development throws).
|
|
484
|
+
*/
|
|
485
|
+
export function gateRenderBoundKeys(
|
|
486
|
+
renderBound: Record<string, BoundStyleValue>,
|
|
487
|
+
mount: {
|
|
488
|
+
readonly mountedBoundKeys: readonly string[]
|
|
489
|
+
readonly animateKeys: readonly string[]
|
|
490
|
+
readonly identityEligibleBoundKeys?: readonly string[]
|
|
491
|
+
/** Exact direct-numeric bindings admitted by an empty-controller replacement mount. */
|
|
492
|
+
readonly identityEligibleGrowBoundKeys?: readonly string[]
|
|
493
|
+
},
|
|
494
|
+
gate: StyleValueGate = THROW_GATE,
|
|
495
|
+
): Record<string, BoundStyleValue> {
|
|
496
|
+
for (const key of Object.keys(renderBound)) {
|
|
497
|
+
// Both crossover directions are refused (r8): a binding appearing on a key the animate
|
|
498
|
+
// prop currently targets is the same dual-authority violation as the reverse. U5 W3: a
|
|
499
|
+
// bound `transform` occupies the WHOLE transform component space, so an animate target on
|
|
500
|
+
// any transform shorthand crosses it.
|
|
501
|
+
const animateCrossover = mount.animateKeys.some(
|
|
502
|
+
(animateKey) =>
|
|
503
|
+
animateKey === key ||
|
|
504
|
+
(key === TRANSFORM_STRING_KEY_NAME && isNativeTransformComponentKey(animateKey)),
|
|
505
|
+
)
|
|
506
|
+
if (animateCrossover && mount.identityEligibleBoundKeys?.includes(key) !== true) {
|
|
507
|
+
const error = new Error(
|
|
508
|
+
key === TRANSFORM_STRING_KEY_NAME
|
|
509
|
+
? `${componentPrefix(gate)}R4 scope: 'transform' is style-bound AND a declarative target names a transform ` +
|
|
510
|
+
'shorthand — a bound transform occupies the WHOLE transform component space, so ' +
|
|
511
|
+
'one authority covers every shorthand (REQ-DRIVER-029).'
|
|
512
|
+
: `${componentPrefix(gate)}R4 scope: '${key}' is a declarative target AND became style-bound — a key has ONE ` +
|
|
513
|
+
'reconciliation authority (REQ-DRIVER-029).',
|
|
514
|
+
)
|
|
515
|
+
if (gate.severity === 'development') throw error
|
|
516
|
+
gate.report(error)
|
|
517
|
+
delete renderBound[key]
|
|
518
|
+
continue
|
|
519
|
+
}
|
|
520
|
+
if (
|
|
521
|
+
!mount.mountedBoundKeys.includes(key) &&
|
|
522
|
+
mount.identityEligibleGrowBoundKeys?.includes(key) !== true
|
|
523
|
+
) {
|
|
524
|
+
const error = new Error(
|
|
525
|
+
`${componentPrefix(gate)}R4 scope: style-bound key '${key}' was not BOUND at mount — binding authority is ` +
|
|
526
|
+
'fixed at mount per key (a declaratively-mounted key keeps its driver registration; ' +
|
|
527
|
+
'a late binding would be a second authority — REQ-API-026/REQ-DRIVER-029).',
|
|
528
|
+
)
|
|
529
|
+
if (gate.severity === 'development') throw error
|
|
530
|
+
gate.report(error)
|
|
531
|
+
delete renderBound[key]
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
return renderBound
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* The bound-key set as an OCCUPANCY list for gates that compare target key names
|
|
539
|
+
* (gateGestureStateProps): a bound `transform` occupies every transform shorthand
|
|
540
|
+
* (U5 W3, REQ-DRIVER-029) — the gesture-state XOR must see the whole component space, not just
|
|
541
|
+
* the literal bound key.
|
|
542
|
+
*/
|
|
543
|
+
export function expandBoundAuthorityKeys(boundKeys: readonly string[]): readonly string[] {
|
|
544
|
+
if (!boundKeys.includes(TRANSFORM_STRING_KEY_NAME)) return boundKeys
|
|
545
|
+
return [...boundKeys, ...NATIVE_TRANSFORM_ORDER]
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
export interface StyleValueBinding {
|
|
549
|
+
/**
|
|
550
|
+
* Subscribe the bound values and materialize their current values through the lane. By
|
|
551
|
+
* default every key pushes; the rebind lane passes ONLY the swapped keys (r12 minor
|
|
552
|
+
* e14a9c72f6b3 — unchanged keys already sit at their live value on the host, so a rebind
|
|
553
|
+
* writes exactly one crossing per changed key).
|
|
554
|
+
*/
|
|
555
|
+
attach(pushKeys?: readonly string[]): void
|
|
556
|
+
detach(): void
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
export function createStyleValueBinding(
|
|
560
|
+
binding: WorkletDriverBinding,
|
|
561
|
+
handle: ElementHandle,
|
|
562
|
+
bound: Readonly<Record<string, BoundStyleValue>>,
|
|
563
|
+
gate: StyleValueGate = THROW_GATE,
|
|
564
|
+
// U5 (packet F3): the rgba-class delivery port — MotionView's onColorEndpoints hook, the SAME
|
|
565
|
+
// port the controller's static-color path feeds (MotionView.tsx:3542). A bound color publishes
|
|
566
|
+
// a fresh settled `[c, c]` sequence per write; the key's driver progress rests at PROGRESS_END
|
|
567
|
+
// from mount (addStaticKeys), so no projection machinery and no numeric lane crossing exists.
|
|
568
|
+
onColorEndpoints?: (key: string, sequence: readonly RGBA[]) => void,
|
|
569
|
+
): StyleValueBinding {
|
|
570
|
+
let unsubscribes: Array<() => void> | null = null
|
|
571
|
+
// The animation-refusal law (review r1 major 1): an animation driving a bound value is
|
|
572
|
+
// per-frame JS→UI traffic. Fail loud naming the successor rung; production reports and
|
|
573
|
+
// stops the offending animation (fail-closed — the element keeps its last written value).
|
|
574
|
+
const refuseAnimation = (key: string, value: BoundStyleValue): void => {
|
|
575
|
+
const error = new Error(
|
|
576
|
+
`${componentPrefix(gate)}R4 scope: style-bound MotionValue on '${key}' is being driven by a JS-side ` +
|
|
577
|
+
'animation — per-frame bridge traffic is refused (REQ-DRIVER-029). Animate the ' +
|
|
578
|
+
'element property directly (driver-side), or wait for the driver-derived channel ' +
|
|
579
|
+
'rung (specs/R4-VALUE-CHANNEL-BUILD-PACKET.md).',
|
|
580
|
+
)
|
|
581
|
+
value.stop()
|
|
582
|
+
if (gate.severity === 'development') throw error
|
|
583
|
+
gate.report(error)
|
|
584
|
+
}
|
|
585
|
+
// One delivery writer per bound entry (U5): rgba-class entries validate the color string and
|
|
586
|
+
// publish the settled sequence; U5 W3's `transform` entry parses the string with the pinned
|
|
587
|
+
// grammar and writes the FULL component map through the SAME numeric retarget lane (one
|
|
588
|
+
// multi-key write per set() — uiApplyWrite validates all keys before mutating any,
|
|
589
|
+
// workletDriver.ts:1501-1518); numeric entries validate per write too (U5 review round 1
|
|
590
|
+
// MAJOR 1 — the render reconcile no longer re-validates held values after 8be94db5, so this
|
|
591
|
+
// arm is the ONLY checkpoint a non-number set() on an attached numeric binding meets:
|
|
592
|
+
// development throws, production reports and keeps the last good write). A color key without
|
|
593
|
+
// the port is a wiring invariant violation (the component always threads it) — loud, never a
|
|
594
|
+
// silent drop.
|
|
595
|
+
const writerFor = (key: string): ((latest: unknown) => void) => {
|
|
596
|
+
if (key === TRANSFORM_STRING_KEY_NAME) {
|
|
597
|
+
return (latest) => {
|
|
598
|
+
const map = boundTransformWriteMap(latest, gate)
|
|
599
|
+
if (map === null) return // production: refuse the write, keep the last good components
|
|
600
|
+
binding.driver.write(handle, map, undefined, 'bound-value-retarget')
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
if (!COLOR_BINDABLE_KEYS.has(key)) {
|
|
604
|
+
return (latest) => {
|
|
605
|
+
if (typeof latest !== 'number') {
|
|
606
|
+
const error = new Error(
|
|
607
|
+
`${componentPrefix(gate)}style-bound MotionValue on '${key}' holds ${describeValue(latest)}; ` +
|
|
608
|
+
'a bound numeric key writes only numbers — the write is refused under the ' +
|
|
609
|
+
'severity law (REQ-API-026, REQ-DRIVER-029).',
|
|
610
|
+
)
|
|
611
|
+
if (gate.severity === 'development') throw error
|
|
612
|
+
gate.report(error)
|
|
613
|
+
return // production: refuse the write, keep the last good value
|
|
614
|
+
}
|
|
615
|
+
binding.driver.write(handle, { [key]: latest }, undefined, 'bound-value-retarget')
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
if (onColorEndpoints === undefined) {
|
|
619
|
+
throw new Error(
|
|
620
|
+
`${componentPrefix(gate)}style-bound color key '${key}' has no onColorEndpoints delivery ` +
|
|
621
|
+
'port — the MotionView wiring invariant is broken (U5, REQ-API-026).',
|
|
622
|
+
)
|
|
623
|
+
}
|
|
624
|
+
const publish = onColorEndpoints
|
|
625
|
+
return (latest) => {
|
|
626
|
+
const parsed = boundColorValue(key, latest, gate)
|
|
627
|
+
if (parsed === null) return // production: refuse the write, keep the last good value
|
|
628
|
+
publish(key, [parsed, parsed]) // settled: from = to (constant projection), packet F3
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return {
|
|
632
|
+
attach(pushKeys?: readonly string[]): void {
|
|
633
|
+
if (unsubscribes !== null) return
|
|
634
|
+
for (const [key, value] of Object.entries(bound)) {
|
|
635
|
+
// A value ALREADY animating at attach gets the same refusal as one that starts while
|
|
636
|
+
// bound (r12 major 4f2c9b7a1e06): a late mount or source swap must not open a
|
|
637
|
+
// per-frame channel the animationStart listener can never observe.
|
|
638
|
+
if (value.isAnimating()) refuseAnimation(key, value)
|
|
639
|
+
// Materialize the CURRENT value at attach (r11): a set() that landed before attachment
|
|
640
|
+
// (the layout-effect window) must reach the host — the subscription alone would wait
|
|
641
|
+
// for the NEXT change and leave the element stale.
|
|
642
|
+
if (pushKeys === undefined || pushKeys.includes(key)) {
|
|
643
|
+
writerFor(key)(value.get())
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
unsubscribes = Object.entries(bound).flatMap(([key, value]) => [
|
|
647
|
+
value.subscribe(writerFor(key)),
|
|
648
|
+
value.on('animationStart', () => refuseAnimation(key, value)),
|
|
649
|
+
])
|
|
650
|
+
},
|
|
651
|
+
detach(): void {
|
|
652
|
+
if (unsubscribes === null) return
|
|
653
|
+
for (const unsubscribe of unsubscribes) unsubscribe()
|
|
654
|
+
unsubscribes = null
|
|
655
|
+
},
|
|
656
|
+
}
|
|
657
|
+
}
|