@unrulysystems/native-motion-web 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/src/View.tsx ADDED
@@ -0,0 +1,1356 @@
1
+ 'use client'
2
+ // The shim's host wrappers: View, Text, and Image are shipped (REQ-API-034 closed set).
3
+ // Thin bindings of the canonical public surface onto motion.div, Text's selected motion.p/span
4
+ // hosts, and Image's motion.img (REQ-WEB-011: the
5
+ // web engine IS motion/react; this file adds normalization and validation, never animation
6
+ // semantics). Targets are validated with core's validateTarget so an invalid target behaves
7
+ // IDENTICALLY on web and native (one API, one behavior); motion props route through the
8
+ // disposition pipeline; unrecognized keys flow to the DOM as plain attributes (they are React
9
+ // infra, not motion props — the typed surface rejects motion-prop typos at compile time).
10
+ import {
11
+ describeValue,
12
+ hostCapabilities,
13
+ resolveInitialOverlay,
14
+ resolveDragConfig,
15
+ resolvedDragAxes,
16
+ targetShapeRefusal,
17
+ validateTargetSnapshot,
18
+ validateVariantEntrySnapshot,
19
+ variantsShapeRefusal,
20
+ type ResolvedDragConfig,
21
+ type Target,
22
+ type TransformTemplate,
23
+ type Transition,
24
+ type TapCallback,
25
+ type VariantEntry,
26
+ type VariantResolver,
27
+ type VariantLabels,
28
+ type VariantsDictionary,
29
+ } from '@unrulysystems/native-motion-core'
30
+ import {
31
+ initialTargetRefusal,
32
+ keyframeTransitionRefusal,
33
+ resolveTransitionForKey,
34
+ } from '@unrulysystems/native-motion-core/internal-driver'
35
+ import {
36
+ isValidMotionProp,
37
+ motion,
38
+ type DOMMotionComponents,
39
+ type MotionProps,
40
+ type MotionStyle,
41
+ } from 'motion/react'
42
+ import type {
43
+ MotionNodeFocusHandlers,
44
+ MotionNodeHoverHandlers,
45
+ Target as PinTarget,
46
+ } from 'motion-dom'
47
+ import {
48
+ createContext,
49
+ createElement,
50
+ forwardRef,
51
+ useContext,
52
+ useRef,
53
+ type ComponentRef,
54
+ type ForwardRefExoticComponent,
55
+ type HTMLAttributes,
56
+ type PropsWithoutRef,
57
+ type ReactNode,
58
+ type Ref,
59
+ type RefAttributes,
60
+ } from 'react'
61
+ import { DISPOSITIONS } from './disposition'
62
+ import { MotionWebRejectionError } from './errors'
63
+ import { ambientMode } from './mode'
64
+ import {
65
+ captureLabelFormWithSeverity,
66
+ normalizeComponentProps,
67
+ type MotionComponentId,
68
+ type NormalizeOptions,
69
+ } from './normalize'
70
+ import { snapPointsToDragTransition } from './snap'
71
+ import { mapWebTransitionAliases, type WebTransition } from './webTransition'
72
+
73
+ function transitionForProperty(
74
+ transition: Transition | undefined,
75
+ property: string,
76
+ ): Transition | undefined {
77
+ return transition === undefined ? undefined : resolveTransitionForKey(transition, property)
78
+ }
79
+
80
+ // The DOM drag-and-drop handlers (onDrag/onDragStart/onDragEnd) are Omitted so the drag family
81
+ // carries Motion's (event, info) callback shape identically to the native entry — motion/react
82
+ // shadows the same DOM handlers on motion.div for exactly this reason (the type-identity gate
83
+ // requires the drag callbacks to be structurally equal cross-engine).
84
+ type IntrinsicWebTargetMembers = Pick<
85
+ PinTarget,
86
+ 'filter' | 'clipPath' | 'boxShadow' | 'backgroundImage'
87
+ >
88
+ type WebTarget = Target & IntrinsicWebTargetMembers
89
+ type WebVariantEntry = WebTarget & { readonly transition?: WebTransition }
90
+ type WebVariantsDictionary = Readonly<Record<string, WebVariantEntry | VariantResolver>>
91
+
92
+ interface ViewBaseProps extends Omit<
93
+ HTMLAttributes<HTMLDivElement>,
94
+ 'style' | 'onDrag' | 'onDragStart' | 'onDragEnd' | 'onAnimationStart'
95
+ > {
96
+ // The universal motion props carry explicit `| undefined` so the cross-entry assignability
97
+ // gate (type-identity.assert.ts) holds under exactOptionalPropertyTypes: the native entry's
98
+ // props tolerate explicit undefined (React pass-through), so the web props must too.
99
+ // R7 (REQ-API-032): the LABEL form is part of the public type on both entries (the identity
100
+ // gate binds the widening) and EXECUTES here — motion/react owns resolution and propagation;
101
+ // the boundary below gates validity at core depth (M3 flipped the deferred posture).
102
+ readonly initial?: WebTarget | VariantLabels | false | undefined
103
+ readonly animate?: WebTarget | VariantLabels | boolean | undefined
104
+ readonly exit?: WebTarget | VariantLabels | undefined
105
+ readonly variants?: VariantsDictionary | WebVariantsDictionary | undefined
106
+ readonly inherit?: boolean | undefined
107
+ readonly transition?: WebTransition | undefined
108
+ // L4 (specs/SPEC-LAYOUT.md): layout projection, DECLARED and forwarded to real
109
+ // motion/react — the web engine does the projection. Entry shapes are gated below under the
110
+ // severity law; malformed values never reach motion as raw TypeErrors.
111
+ readonly layout?: boolean | 'position' | 'size' | 'preserve-aspect'
112
+ readonly layoutId?: string
113
+ // R11 M2 (REQ-LAYOUT-022 / REQ-API-038): scroll-offset measurement root — forwarded to the
114
+ // pin's projection node; web never re-implements projection.
115
+ readonly layoutScroll?: boolean
116
+ // motion's own style law (r15 d32a7c1e94b6): per-key MotionValue bindings are part of the
117
+ // supported public surface — CSSProperties rejected them and made universal code web-fail.
118
+ readonly style?: MotionStyle
119
+ // The tap family (R5, REQ-API-030): core's shared TapCallback — identical on both entries
120
+ // (the DragInfo precedent); PASSed through to motion's own recognizers (disposition table).
121
+ readonly onTap?: TapCallback
122
+ readonly onTapStart?: TapCallback
123
+ readonly onTapCancel?: TapCallback
124
+ // The gesture-state pair (R6, REQ-API-031; R7 REQ-API-032 adds the label form) —
125
+ // structurally identical to the native entry (the type-identity gate binds it). Labels
126
+ // validate string-only at the normalize boundary and execute through motion's own
127
+ // variants machinery.
128
+ readonly whileTap?:
129
+ | (WebTarget & { readonly transition?: WebTransition })
130
+ | VariantLabels
131
+ | undefined
132
+ readonly whileDrag?:
133
+ | (WebTarget & { readonly transition?: WebTransition })
134
+ | VariantLabels
135
+ | undefined
136
+ readonly whileHover?: MotionNodeHoverHandlers['whileHover'] | undefined
137
+ readonly whileFocus?: MotionNodeFocusHandlers['whileFocus'] | undefined
138
+ readonly onHoverStart?: (...args: unknown[]) => void
139
+ readonly onHoverEnd?: (...args: unknown[]) => void
140
+ readonly onAnimationComplete?: ((definition: unknown) => void) | undefined
141
+ readonly onLayoutAnimationComplete?: (() => void) | undefined
142
+ // T23 C (specs/T23-MIX-DISCRETE-BUILD-PACKET.md): per-frame latest-values callback — DECLARED
143
+ // and forwarded to real motion/react, which owns the frame loop and payload on web. The payload
144
+ // type is the shared cross-entry shape (string | number leaves keyed by public prop name); the
145
+ // type-identity gate binds it to the native entry's projection-lane member.
146
+ readonly onUpdate?: ((latest: Readonly<Record<string, string | number>>) => void) | undefined
147
+ // T17 (specs/T17-TRANSFORM-TEMPLATE-BUILD-PACKET.md): the pin's per-frame transform template.
148
+ // Core's shared TransformTemplate type — identical on both entries by construction (the
149
+ // TapCallback precedent); forwarded to motion/react, which owns the per-frame call. The
150
+ // 'worklet' directive the native mount gate requires is inert on web.
151
+ readonly transformTemplate?: TransformTemplate | undefined
152
+ // T24 B1: the dynamic-variants custom payload — forwarded to motion/react, which owns
153
+ // resolver invocation on this engine (`unknown` on both entries by construction).
154
+ readonly custom?: unknown
155
+ }
156
+
157
+ // Standard drag props remain the pin's exact web surface. Native Motion's `dragSnapPoints`
158
+ // extension is the only overlay: it remains axis-discriminated because the pin's single
159
+ // `modifyTarget` callback cannot represent independent two-axis snap arrays.
160
+ type PinnedWebDragPropKeys =
161
+ | 'drag'
162
+ | 'dragConstraints'
163
+ | 'dragElastic'
164
+ | 'dragPropagation'
165
+ | 'dragControls'
166
+ | 'dragListener'
167
+ | 'onMeasureDragConstraints'
168
+ | 'onDragStart'
169
+ | 'onDrag'
170
+ | 'onDragEnd'
171
+ | 'dragDirectionLock'
172
+ | 'onDirectionLock'
173
+ | 'dragMomentum'
174
+ | 'dragTransition'
175
+ | 'dragSnapToOrigin'
176
+ | '_dragX'
177
+ | '_dragY'
178
+
179
+ type PinnedWebDragProps = Pick<MotionProps, PinnedWebDragPropKeys>
180
+ type NonEmptyNumberArray = readonly [number, ...number[]]
181
+ type WebDragSnapExtension =
182
+ | { readonly dragSnapPoints?: undefined }
183
+ | {
184
+ readonly drag: 'x'
185
+ readonly dragSnapPoints: {
186
+ readonly x: NonEmptyNumberArray
187
+ readonly y?: never
188
+ }
189
+ }
190
+ | {
191
+ readonly drag: 'y'
192
+ readonly dragSnapPoints: {
193
+ readonly y: NonEmptyNumberArray
194
+ readonly x?: never
195
+ }
196
+ }
197
+
198
+ type PinnedWebViewRemainderKeys =
199
+ | 'onAnimationStart'
200
+ | 'onBeforeLayoutMeasure'
201
+ | 'onLayoutMeasure'
202
+ | 'onLayoutAnimationStart'
203
+ | 'onDragTransitionEnd'
204
+ | 'onPanSessionStart'
205
+ | 'onPanStart'
206
+ | 'onPan'
207
+ | 'onPanEnd'
208
+ | 'globalTapTarget'
209
+ | 'whileInView'
210
+ | 'onViewportEnter'
211
+ | 'onViewportLeave'
212
+ | 'viewport'
213
+ | 'layoutDependency'
214
+ | 'layoutRoot'
215
+ | 'layoutAnchor'
216
+ | 'layoutCrossfade'
217
+ | 'ignoreStrict'
218
+ | 'values'
219
+ | 'propagate'
220
+ | 'data-framer-portal-id'
221
+ | 'data-framer-appear-id'
222
+
223
+ type PinnedWebViewRemainder = Pick<MotionProps, PinnedWebViewRemainderKeys>
224
+
225
+ export type ViewProps = ViewBaseProps &
226
+ PinnedWebDragProps &
227
+ WebDragSnapExtension &
228
+ PinnedWebViewRemainder
229
+
230
+ // The web host declares every registry class the pinned DOM engine actually supports. Portable
231
+ // members still ride core validation; intrinsic-web complex effects bypass Native Motion's value
232
+ // normalizer and remain owned by motion/react (REQ-WEB-019).
233
+ const WEB_HOST = hostCapabilities('web', ['universal', 'web-only', 'pointer-gated'])
234
+ const INTRINSIC_WEB_TARGET_KEYS = new Set<string>([
235
+ 'filter',
236
+ 'clipPath',
237
+ 'boxShadow',
238
+ 'backgroundImage',
239
+ ])
240
+
241
+ function isIntrinsicWebTargetKey(key: string): boolean {
242
+ return INTRINSIC_WEB_TARGET_KEYS.has(key)
243
+ }
244
+
245
+ function validateWebVariantEntrySnapshot(
246
+ label: string,
247
+ entry: unknown,
248
+ componentId: MotionComponentId,
249
+ ): ReturnType<typeof validateVariantEntrySnapshot> {
250
+ if (typeof entry === 'function' || typeof entry !== 'object' || entry === null) {
251
+ return validateVariantEntrySnapshot(label, entry, WEB_HOST, { componentId })
252
+ }
253
+ const prototype = Object.getPrototypeOf(entry)
254
+ if (prototype !== Object.prototype && prototype !== null) {
255
+ return validateVariantEntrySnapshot(label, entry, WEB_HOST, { componentId })
256
+ }
257
+ const portable: Record<string, unknown> = Object.create(null)
258
+ const intrinsic: Record<string, unknown> = Object.create(null)
259
+ let hasTransition = false
260
+ let transition: unknown
261
+ for (const [key, value] of Object.entries(entry)) {
262
+ if (key === 'transition') {
263
+ hasTransition = true
264
+ transition = value
265
+ } else if (isIntrinsicWebTargetKey(key)) intrinsic[key] = value
266
+ else portable[key] = value
267
+ }
268
+ const verdict = validateVariantEntrySnapshot(label, portable, WEB_HOST, { componentId })
269
+ if (verdict.refusal !== null || typeof verdict.entry === 'function' || verdict.entry === null) {
270
+ return verdict
271
+ }
272
+ const accepted = Object.assign(Object.create(null), verdict.entry, intrinsic) as Record<
273
+ string,
274
+ unknown
275
+ >
276
+ if (hasTransition) accepted['transition'] = transition
277
+ return {
278
+ refusal: null,
279
+ entry: accepted as VariantEntry,
280
+ }
281
+ }
282
+
283
+ // Component-level motion props DECLARED on ViewProps that live outside the disposition table's
284
+ // registry domain (they are lifecycle plumbing, not animatable capabilities). Anything motion
285
+ // would interpret that is neither here nor in the table is an undeclared capability and fails
286
+ // loud below.
287
+ const DECLARED_COMPONENT_PROPS = new Set([
288
+ 'onAnimationComplete',
289
+ 'onLayoutAnimationComplete',
290
+ // T23 C: motion's own per-frame latest callback — same lifecycle-plumbing lane.
291
+ 'onUpdate',
292
+ ])
293
+
294
+ // Motion owns actual label resolution and propagation. The shim carries only the accepted label
295
+ // selections so each descendant can validate its OWN dictionary against its OWN transition before
296
+ // handing the original (or production-sanitized) label to Motion. This prevents a parent-internal
297
+ // sanitized alias from becoming an observable propagated label at the next dictionary boundary.
298
+ interface VariantTimingSelections {
299
+ readonly initial: readonly string[] | false | undefined
300
+ readonly animate: readonly string[] | undefined
301
+ readonly exit: readonly string[] | undefined
302
+ readonly whileTap: readonly string[] | undefined
303
+ readonly whileDrag: readonly string[] | undefined
304
+ }
305
+
306
+ const EMPTY_VARIANT_TIMING_SELECTIONS: VariantTimingSelections = {
307
+ initial: undefined,
308
+ animate: undefined,
309
+ exit: undefined,
310
+ whileTap: undefined,
311
+ whileDrag: undefined,
312
+ }
313
+
314
+ const VariantTimingContext = createContext<VariantTimingSelections>(EMPTY_VARIANT_TIMING_SELECTIONS)
315
+
316
+ // A Text host marks its rendered descendant tree. Public View consumes that marker at its own
317
+ // render boundary so wrappers are judged by what they render, never by predictive invocation.
318
+ const TextNestingContext = createContext<NormalizeOptions | null>(null)
319
+
320
+ function componentDragError(
321
+ componentId: MotionComponentId,
322
+ error: unknown,
323
+ ): MotionWebRejectionError {
324
+ const message = error instanceof Error ? error.message : describeValue(error)
325
+ return new MotionWebRejectionError(`${componentId}: ${message}`)
326
+ }
327
+
328
+ // CORE owns the drag verdict, while each public host owns the public diagnostic identity. Keeping
329
+ // that identity mandatory at this adapter prevents a new Text lane from reporting anonymously.
330
+ function resolveDragConfigForComponent(
331
+ input: Parameters<typeof resolveDragConfig>[0],
332
+ options: NormalizeOptions,
333
+ componentId: MotionComponentId,
334
+ ): ResolvedDragConfig | null {
335
+ try {
336
+ return resolveDragConfig(input, options.mode, (error) =>
337
+ options.report(componentDragError(componentId, error)),
338
+ )
339
+ } catch (error) {
340
+ const rejection = componentDragError(componentId, error)
341
+ if (options.mode === 'development') throw rejection
342
+ options.report(rejection)
343
+ return null
344
+ }
345
+ }
346
+
347
+ // Validate a Target with the ratified severity law. Development: core's validateTarget throws
348
+ // through untouched (its InvalidTargetError names component, key, value, reason). Production:
349
+ // validated PER KEY so only the offending property is refused — the catch below IS the ratified
350
+ // production error channel ("report through the error channel and refuse the property", BRIEF
351
+ // Decisions 2026-07-06), not suppression: every failure is reported, the property never applies.
352
+ // Exported for the presence container (round 12 major 38): the container gates its children's
353
+ // targets through the SAME lane the View uses, so failure categories and ordering mirror the
354
+ // native container exactly (REQ-PRESENCE-019).
355
+ // The transition counterpart of validateWithSeverity (r7 major 41b089c8c0af): core
356
+ // validateTransition is the single authority (shape, vocabulary, ranges, the derived-config
357
+ // probe); the severity split mirrors the target lane exactly.
358
+ export function gateTransitionWithSeverity(
359
+ value: WebTransition,
360
+ options: NormalizeOptions,
361
+ componentId: string = '<View>',
362
+ ): WebTransition | undefined {
363
+ try {
364
+ return mapWebTransitionAliases(value, componentId)
365
+ } catch (error) {
366
+ if (!(error instanceof MotionWebRejectionError)) throw error
367
+ if (options.mode === 'development') throw error
368
+ options.report(error)
369
+ return undefined
370
+ }
371
+ }
372
+
373
+ export function validateWithSeverity(
374
+ target: Target,
375
+ propName: string,
376
+ options: NormalizeOptions,
377
+ transition: Transition | undefined = undefined,
378
+ componentId: MotionComponentId = '<View>',
379
+ ): Target {
380
+ return validateWithSeverityResult(target, propName, options, transition, false, componentId)
381
+ .target
382
+ }
383
+
384
+ export interface SeverityTargetResult {
385
+ readonly target: Target
386
+ /** Keys that reached the effective-transition predicate from the captured target snapshot. */
387
+ readonly attemptedKeys: readonly string[]
388
+ }
389
+
390
+ // Keep direct-target ownership on the same one-read snapshot that crosses into motion/react. A
391
+ // property that passed target validation but was then refused by its effective timing transition is
392
+ // an attempted fallback owner; an absent or malformed target is not. This distinction prevents an
393
+ // inert element transition from surviving a reported per-property refusal without turning a truly
394
+ // ownerless transition into a silent no-op.
395
+ export function validateWithSeverityResult(
396
+ target: WebTarget,
397
+ propName: string,
398
+ options: NormalizeOptions,
399
+ transition: Transition | undefined = undefined,
400
+ initialOnly = false,
401
+ componentId: MotionComponentId = '<View>',
402
+ ): SeverityTargetResult {
403
+ const shapeRefusal = targetShapeRefusal(target, { componentId })
404
+ if (shapeRefusal !== null) {
405
+ if (options.mode === 'development') throw shapeRefusal
406
+ options.report(
407
+ new MotionWebRejectionError(`${propName} refused: ${shapeRefusal.message}`, {
408
+ cause: shapeRefusal,
409
+ }),
410
+ )
411
+ return { target: {} as Target, attemptedKeys: [] }
412
+ }
413
+ if (options.mode === 'development') {
414
+ // validateTargetSnapshot shape-gates first (round 9): a malformed non-object target throws the
415
+ // typed InvalidTargetError here, never a raw TypeError out of Object.keys. It also materializes
416
+ // each keyframe ARRAY member ONCE and RETURNS that snapshot (R8, review major 23): dev forwards
417
+ // the snapshot, not the caller's array, so motion/react's later read never re-fires an accessor.
418
+ const accepted: Record<string, unknown> = Object.create(null)
419
+ const attemptedKeys: string[] = []
420
+ for (const [key, rawValue] of Object.entries(target)) {
421
+ if (isIntrinsicWebTargetKey(key)) {
422
+ accepted[key] = rawValue
423
+ attemptedKeys.push(key)
424
+ continue
425
+ }
426
+ const { refusal, target: snapshot } = validateTargetSnapshot({ [key]: rawValue }, WEB_HOST, {
427
+ componentId,
428
+ })
429
+ if (refusal !== null) throw refusal
430
+ const value = (snapshot as Record<string, unknown>)[key]
431
+ const initialRefusal = initialOnly ? initialTargetRefusal(componentId, key, value) : null
432
+ if (initialRefusal !== null) throw initialRefusal
433
+ const timingRefusal = keyframeTransitionRefusal(
434
+ componentId,
435
+ key,
436
+ value,
437
+ transitionForProperty(transition, key),
438
+ )
439
+ if (timingRefusal !== null) throw timingRefusal
440
+ accepted[key] = value
441
+ attemptedKeys.push(key)
442
+ }
443
+ return { target: accepted as Target, attemptedKeys }
444
+ }
445
+ // Whole-target shape gate for the production lane (round 9 major 31): Object.entries below
446
+ // coerced numbers/booleans to the empty target with NO report and raw-TypeErrored on null —
447
+ // a malformed shape is refused AS A UNIT through the ratified error channel. RETURN-shaped,
448
+ // no catch (M3 r8 major b9eb82c0d429): only core's returned refusals are reported and
449
+ // dropped; a dependency fault thrown through validation keeps its exact identity.
450
+ const kept: Record<string, unknown> = {}
451
+ const attemptedKeys: string[] = []
452
+ for (const [key, value] of Object.entries(target)) {
453
+ if (isIntrinsicWebTargetKey(key)) {
454
+ kept[key] = value
455
+ attemptedKeys.push(key)
456
+ continue
457
+ }
458
+ // validateTargetSnapshot materializes the member's array ONCE before validation and returns it;
459
+ // the KEPT value is that snapshot, never the caller's raw array (one read, one truth — R8,
460
+ // review major 23), so motion/react never re-reads an accessor and lands an unvalidated value.
461
+ const { refusal, target: snapshot } = validateTargetSnapshot({ [key]: value }, WEB_HOST, {
462
+ componentId,
463
+ })
464
+ if (refusal !== null) {
465
+ options.report(
466
+ new MotionWebRejectionError(`${propName}.${key} refused: ${refusal.message}`, {
467
+ cause: refusal,
468
+ }),
469
+ )
470
+ continue
471
+ }
472
+ const valueSnapshot = (snapshot as Record<string, unknown>)[key]
473
+ attemptedKeys.push(key)
474
+ const initialRefusal = initialOnly
475
+ ? initialTargetRefusal(componentId, key, valueSnapshot)
476
+ : null
477
+ if (initialRefusal !== null) {
478
+ options.report(
479
+ new MotionWebRejectionError(`${propName}.${key} refused: ${initialRefusal.message}`, {
480
+ cause: initialRefusal,
481
+ }),
482
+ )
483
+ continue
484
+ }
485
+ const timingRefusal = keyframeTransitionRefusal(
486
+ componentId,
487
+ key,
488
+ valueSnapshot,
489
+ transitionForProperty(transition, key),
490
+ )
491
+ if (timingRefusal !== null) {
492
+ options.report(
493
+ new MotionWebRejectionError(`${propName}.${key} refused: ${timingRefusal.message}`, {
494
+ cause: timingRefusal,
495
+ }),
496
+ )
497
+ continue
498
+ }
499
+ kept[key] = valueSnapshot
500
+ }
501
+ return { target: kept as Target, attemptedKeys }
502
+ }
503
+
504
+ // R7 (REQ-API-032 law f): the web dictionary gate — outer shape AS A UNIT, every entry
505
+ // eagerly through core's snapshot validator over the WEB host (core validity + the full
506
+ // transition vocabulary; the native-only drivability/option laws stay native — this engine
507
+ // drives units and executes delay/velocity). Development throws typed; production reports
508
+ // and refuses the offending UNIT: the whole dictionary for an outer-shape offense, the
509
+ // single ENTRY inside it. RETURN-shaped, no catch (M3 r3 majors 1b53e9337aec/13af48a64530):
510
+ // the shape probe and the entry READ phase run foreign traps unguarded — faults, including
511
+ // authentic same-class sentinels and replayed refusals, propagate untouched by
512
+ // construction. The accumulator is null-prototype so an own '__proto__' label can never
513
+ // pollute; the accepted value is the SNAPSHOT (one read, one truth) — resolution against
514
+ // it is motion/react's (REQ-WEB-011).
515
+ function gateVariantsWithSeverity(
516
+ value: unknown,
517
+ options: NormalizeOptions,
518
+ componentId: MotionComponentId = '<View>',
519
+ ): VariantsDictionary | undefined {
520
+ const shapeRefusal = variantsShapeRefusal(value)
521
+ if (shapeRefusal !== null) {
522
+ const ownedRefusal = new MotionWebRejectionError(`${componentId}: ${shapeRefusal.message}`, {
523
+ cause: shapeRefusal,
524
+ })
525
+ if (options.mode === 'development') throw ownedRefusal
526
+ options.report(ownedRefusal)
527
+ return undefined
528
+ }
529
+ const accepted = Object.create(null) as Record<string, VariantEntry | VariantResolver>
530
+ for (const [label, entry] of Object.entries(value as Record<string, unknown>)) {
531
+ const { refusal, entry: snapshot } = validateWebVariantEntrySnapshot(label, entry, componentId)
532
+ if (refusal !== null) {
533
+ if (options.mode === 'development') throw refusal
534
+ options.report(new MotionWebRejectionError(refusal.message, { cause: refusal }))
535
+ continue
536
+ }
537
+ // T24 B2: a resolver entry is accepted by identity. Static entries retain pin-owned nested
538
+ // transitions; only Native Motion's easing aliases are mapped before the pin receives them.
539
+ if (typeof snapshot === 'function' || snapshot === null) {
540
+ accepted[label] = snapshot!
541
+ continue
542
+ }
543
+ const rawTransition = (snapshot as Record<string, unknown>)['transition']
544
+ if (rawTransition === undefined) {
545
+ accepted[label] = snapshot
546
+ continue
547
+ }
548
+ const mappedTransition = gateTransitionWithSeverity(
549
+ rawTransition as WebTransition,
550
+ options,
551
+ `${componentId}.variants.${label}`,
552
+ )
553
+ if (mappedTransition === undefined) continue
554
+ accepted[label] =
555
+ mappedTransition === rawTransition
556
+ ? snapshot
557
+ : (Object.assign(Object.create(null), snapshot, {
558
+ transition: mappedTransition,
559
+ }) as VariantEntry)
560
+ }
561
+ return accepted as VariantsDictionary
562
+ }
563
+
564
+ function labelNames(value: unknown): readonly string[] | undefined {
565
+ if (typeof value === 'string') return [value]
566
+ if (Array.isArray(value) && value.every((member) => typeof member === 'string')) {
567
+ return value as readonly string[]
568
+ }
569
+ return undefined
570
+ }
571
+
572
+ // Only labels selected by an animation-time supplier consume the element transition. `initial`
573
+ // resolves synchronously and deliberately does not appear here: its scalar first-paint target is
574
+ // legal beside a keyframed animate label using the same transition.
575
+ function gateVariantTimingConsumers(
576
+ dictionary: VariantsDictionary | undefined,
577
+ labels: readonly string[],
578
+ initialLabels: readonly string[] | undefined,
579
+ elementTransition: Transition | undefined,
580
+ options: NormalizeOptions,
581
+ componentId: MotionComponentId = '<View>',
582
+ ): {
583
+ readonly dictionary: VariantsDictionary | undefined
584
+ readonly filteredLabels: ReadonlySet<string>
585
+ readonly initialProjection: VariantsDictionary | undefined
586
+ readonly initialFilteredLabels: ReadonlySet<string>
587
+ readonly hasElementTransitionOwner: boolean
588
+ readonly attemptedElementTransitionOwner: boolean
589
+ } {
590
+ if (
591
+ dictionary === undefined ||
592
+ (elementTransition === undefined && initialLabels === undefined) ||
593
+ (labels.length === 0 && initialLabels === undefined)
594
+ )
595
+ return {
596
+ dictionary,
597
+ filteredLabels: new Set(),
598
+ initialProjection: dictionary,
599
+ initialFilteredLabels: new Set(),
600
+ hasElementTransitionOwner: false,
601
+ attemptedElementTransitionOwner: false,
602
+ }
603
+ const selected = new Set(labels)
604
+ const selectedInitial = new Set(initialLabels)
605
+ const accepted = Object.create(null) as Record<string, VariantEntry | VariantResolver>
606
+ const filteredLabels = new Set<string>()
607
+ const initialProjection = Object.create(null) as Record<string, VariantEntry>
608
+ const initialFilteredLabels = new Set<string>()
609
+ let hasElementTransitionOwner = false
610
+ let attemptedElementTransitionOwner = false
611
+ for (const [label, entry] of Object.entries(dictionary)) {
612
+ if (typeof entry === 'function') {
613
+ // T24 B2: a RESOLVER entry's target keys are unknowable before Motion invokes it at
614
+ // activation, so the per-key initial/timing sanitization below cannot apply — forward
615
+ // it untouched (the underlying motion/react owns dynamic resolution on this engine).
616
+ // Unreachable until the entry boundary accepts the form (validateVariantEntrySnapshot).
617
+ accepted[label] = entry
618
+ continue
619
+ }
620
+ const consumesInitial = selectedInitial.has(label)
621
+ const consumesTiming = selected.has(label) && entry.transition === undefined
622
+ if (!consumesInitial && !consumesTiming) {
623
+ accepted[label] = entry
624
+ continue
625
+ }
626
+ const targetEntries = Object.entries(entry).filter(([key]) => key !== 'transition')
627
+ const initial = Object.create(null) as Record<string, unknown>
628
+ let initialChanged = false
629
+ const filtered = Object.create(null) as Record<string, unknown>
630
+ let timingChanged = false
631
+ // The selected entry has handed these properties to the element transition. Preserve the
632
+ // attempted-vs-accepted distinction after filtering: a rejected scalar owns its one report,
633
+ // while only a retained property can keep the transition executable.
634
+ if (consumesTiming && targetEntries.length > 0) attemptedElementTransitionOwner = true
635
+ for (const [key, target] of targetEntries) {
636
+ if (isIntrinsicWebTargetKey(key)) {
637
+ if (consumesInitial) initial[key] = target
638
+ if (consumesTiming) filtered[key] = target
639
+ continue
640
+ }
641
+ const initialRefusal = consumesInitial ? initialTargetRefusal(componentId, key, target) : null
642
+ const timingRefusal = consumesTiming
643
+ ? keyframeTransitionRefusal(
644
+ componentId,
645
+ key,
646
+ target,
647
+ transitionForProperty(elementTransition, key),
648
+ )
649
+ : null
650
+ if (initialRefusal !== null) {
651
+ const error = new MotionWebRejectionError(initialRefusal.message, { cause: initialRefusal })
652
+ if (options.mode === 'development') throw error
653
+ options.report(error)
654
+ initialChanged = true
655
+ } else if (consumesInitial) {
656
+ initial[key] = target
657
+ }
658
+ if (timingRefusal !== null) {
659
+ const error = new MotionWebRejectionError(timingRefusal.message, { cause: timingRefusal })
660
+ if (options.mode === 'development') throw error
661
+ options.report(error)
662
+ timingChanged = true
663
+ } else if (consumesTiming) {
664
+ filtered[key] = target
665
+ }
666
+ }
667
+ if (consumesInitial) {
668
+ initialProjection[label] = initial as VariantEntry
669
+ if (initialChanged) initialFilteredLabels.add(label)
670
+ }
671
+ if (!consumesTiming) {
672
+ accepted[label] = entry
673
+ continue
674
+ }
675
+ if (timingChanged) {
676
+ // Motion already resolves this exact label (including inherited selections). Sanitise its
677
+ // original dictionary entry instead of forwarding a synthetic alias as a local prop: aliases
678
+ // change Motion's controlling/propagation semantics. Initial overlays use the separately
679
+ // scalar-gated projection, so this timing filter can never reintroduce an initial array.
680
+ filteredLabels.add(label)
681
+ accepted[label] = filtered as VariantEntry
682
+ if (Object.keys(filtered).length > 0) hasElementTransitionOwner = true
683
+ continue
684
+ }
685
+ accepted[label] = entry
686
+ if (targetEntries.length > 0) hasElementTransitionOwner = true
687
+ }
688
+ return {
689
+ dictionary: accepted as VariantsDictionary,
690
+ filteredLabels,
691
+ initialProjection: initialProjection as VariantsDictionary,
692
+ initialFilteredLabels,
693
+ hasElementTransitionOwner,
694
+ attemptedElementTransitionOwner,
695
+ }
696
+ }
697
+
698
+ function mappedLabels(
699
+ labels: readonly string[] | undefined,
700
+ aliases: ReadonlyMap<string, string>,
701
+ ): string | readonly string[] | undefined {
702
+ if (labels === undefined) return undefined
703
+ const mapped = labels.map((label) => aliases.get(label) ?? label)
704
+ return mapped.length === 1 ? mapped[0] : mapped
705
+ }
706
+
707
+ // R7 (REQ-API-032 law g): the ACCEPTED dictionary target keys for the presence container's
708
+ // mounted-key union (animate keys ∪ dictionary keys). REPORT-silent, never severity-silent
709
+ // (M3 r2 major 94ad45a8210d, the native silent-gate pattern): development still THROWS the
710
+ // typed law-(f) refusal, so dictionary validation always outruns the exit-subset rule and
711
+ // the error category matches native exactly; production skips the refused unit without a
712
+ // report — the child View owns the one report in this same render pass. RETURN-shaped, no
713
+ // catch (M3 r3 major 1b53e9337aec; the r2 ownership mechanism is superseded): a returned
714
+ // refusal is boundary-made by construction, and anything thrown — a trap fault, a forged
715
+ // or replayed sentinel — propagates untouched because nothing here catches it.
716
+ export function silentDictionaryTargetKeys(
717
+ value: unknown,
718
+ options: NormalizeOptions,
719
+ componentId: MotionComponentId = '<View>',
720
+ ): ReadonlySet<string> {
721
+ const keys = new Set<string>()
722
+ if (value === undefined) return keys
723
+ const shapeRefusal = variantsShapeRefusal(value)
724
+ if (shapeRefusal !== null) {
725
+ if (options.mode === 'development')
726
+ throw new MotionWebRejectionError(`${componentId}: ${shapeRefusal.message}`, {
727
+ cause: shapeRefusal,
728
+ })
729
+ return keys
730
+ }
731
+ for (const [label, entry] of Object.entries(value as Record<string, unknown>)) {
732
+ const { refusal, entry: snapshot } = validateWebVariantEntrySnapshot(label, entry, componentId)
733
+ if (refusal !== null) {
734
+ if (options.mode === 'development') throw refusal
735
+ continue
736
+ }
737
+ for (const key of Object.keys(snapshot!)) {
738
+ if (key !== 'transition') keys.add(key)
739
+ }
740
+ }
741
+ return keys
742
+ }
743
+
744
+ // R7 (REQ-API-032 law f): inherit is a boolean switch — anything else refuses typed,
745
+ // naming the contract (the native gateInheritProp's depth).
746
+ function gateInheritWithSeverity(
747
+ value: unknown,
748
+ options: NormalizeOptions,
749
+ componentId: MotionComponentId = '<View>',
750
+ ): boolean | undefined {
751
+ if (typeof value === 'boolean') return value
752
+ const error = new MotionWebRejectionError(
753
+ `${componentId}: "inherit" must be a boolean — received ${describeValue(value)} (REQ-API-032 law f)`,
754
+ )
755
+ if (options.mode === 'development') throw error
756
+ options.report(error)
757
+ return undefined
758
+ }
759
+
760
+ const isLabelForm = (value: unknown): value is VariantLabels =>
761
+ typeof value === 'string' || Array.isArray(value)
762
+
763
+ type MotionHostElement = DOMMotionComponents[keyof DOMMotionComponents]
764
+ type MotionHostProps<Host extends MotionHostElement> = ViewProps &
765
+ Omit<React.ComponentPropsWithoutRef<Host>, keyof ViewProps | 'ref'>
766
+ type MotionHostView<Host extends MotionHostElement> = ForwardRefExoticComponent<
767
+ PropsWithoutRef<MotionHostProps<Host>> & RefAttributes<ComponentRef<Host>>
768
+ >
769
+
770
+ type HostRole = 'view' | 'text' | 'image'
771
+
772
+ function hasChildrenPayload(children: ReactNode | undefined): boolean {
773
+ return children !== undefined && children !== null && typeof children !== 'boolean'
774
+ }
775
+
776
+ function createViewForHost<Host extends MotionHostElement>(
777
+ element: Host,
778
+ options: NormalizeOptions,
779
+ role: HostRole = 'view',
780
+ ): MotionHostView<Host> {
781
+ const textHost = role === 'text'
782
+ const componentId: MotionComponentId =
783
+ role === 'text' ? '<Text>' : role === 'image' ? '<Image>' : '<View>'
784
+ const HostViewRuntime = forwardRef<ComponentRef<Host>, MotionHostProps<Host>>(function View(
785
+ props,
786
+ ref: Ref<ComponentRef<Host>>,
787
+ ) {
788
+ const inheritedTimingSelections = useContext(VariantTimingContext)
789
+ // Validation-only config default (pin owns runtime inherit; REQ-API-033 cardinality parity).
790
+ // The selected host contributes only its host attributes to the public component type. The
791
+ // runtime normalization contract remains the shared View subset, so recover that stable
792
+ // internal shape before routing the universal props below.
793
+ const viewProps = props as unknown as ViewProps & Record<string, unknown>
794
+ const {
795
+ initial,
796
+ animate,
797
+ exit,
798
+ transition,
799
+ variants,
800
+ inherit,
801
+ layout,
802
+ layoutId,
803
+ layoutScroll,
804
+ // The drag family is destructured OUT of `rest` (FLAG 5a): core resolveDragConfig is the
805
+ // single cross-engine verdict, and the mapping to motion's props is built below — so a
806
+ // production refusal drops the whole family (never a lenient survivor) and dragSnapPoints
807
+ // maps to dragTransition via nearestSnap over the SAME core-validated points native uses.
808
+ drag,
809
+ dragConstraints,
810
+ dragElastic,
811
+ dragSnapPoints,
812
+ dragPropagation,
813
+ dragControls,
814
+ dragListener,
815
+ dragDirectionLock,
816
+ onDirectionLock,
817
+ dragMomentum,
818
+ dragTransition,
819
+ dragSnapToOrigin,
820
+ _dragX,
821
+ _dragY,
822
+ onDragStart,
823
+ onDrag,
824
+ onDragEnd,
825
+ onPanSessionStart,
826
+ onPanStart,
827
+ onPan,
828
+ onPanEnd,
829
+ onMeasureDragConstraints,
830
+ transformTemplate,
831
+ custom,
832
+ children: suppliedChildren,
833
+ ...rest
834
+ } = viewProps
835
+ // Image is childless (REQ-API-034 law c/e): refuse any children payload at host render time.
836
+ let children = suppliedChildren
837
+ if (role === 'image' && hasChildrenPayload(suppliedChildren)) {
838
+ const error = new MotionWebRejectionError(
839
+ `${componentId}: Image is childless — a children payload is refused (REQ-API-034).`,
840
+ )
841
+ if (options.mode === 'development') throw error
842
+ options.report(error)
843
+ children = undefined
844
+ }
845
+
846
+ // Standard Motion callbacks remain pin-owned even beside the Native Motion snap extension.
847
+ // The extension validates only its own axis/points and never changes callback fault timing.
848
+ // T17: a present template must be a function — a malformed value is a typed failure under the
849
+ // severity law, never a raw TypeError inside motion's per-frame build-transform call. Web
850
+ // needs no worklet brand: motion/react calls the plain function on the main thread.
851
+ let gatedTransformTemplate = transformTemplate
852
+ if (transformTemplate !== undefined && typeof transformTemplate !== 'function') {
853
+ const error = new MotionWebRejectionError(
854
+ `${componentId}: "transformTemplate" must be a function, received ${typeof transformTemplate} — the ` +
855
+ 'pin per-frame transform template (T17).',
856
+ )
857
+ if (options.mode === 'development') throw error
858
+ options.report(error)
859
+ gatedTransformTemplate = undefined
860
+ }
861
+ // Only the Native Motion snap extension enters the shared resolver. Standard pin drag props
862
+ // bypass this gate completely and remain owned by motion/react.
863
+ const anyDragProp = dragSnapPoints !== undefined
864
+ const currentDragConfig: ResolvedDragConfig | null = anyDragProp
865
+ ? resolveDragConfigForComponent(
866
+ {
867
+ drag,
868
+ dragSnapPoints,
869
+ },
870
+ options,
871
+ componentId,
872
+ )
873
+ : null
874
+
875
+ const dragBag: Record<string, unknown> = {}
876
+ // REQ-WEB-021: standard Motion drag props are pin-owned. Forward the current render's raw
877
+ // values without Native Motion validation, cloning, ref reads, callback wrappers, or mount
878
+ // freezing. `dragSnapPoints` itself is stripped; when present its modifyTarget mapping is
879
+ // merged after the authored inertia options and therefore intentionally wins that one key.
880
+ const rawDragProps: Readonly<Record<string, unknown>> = {
881
+ drag,
882
+ dragConstraints,
883
+ dragElastic,
884
+ dragPropagation,
885
+ dragControls,
886
+ dragListener,
887
+ dragDirectionLock,
888
+ onDirectionLock,
889
+ dragMomentum,
890
+ dragTransition,
891
+ dragSnapToOrigin,
892
+ _dragX,
893
+ _dragY,
894
+ onDragStart,
895
+ onDrag,
896
+ onDragEnd,
897
+ onMeasureDragConstraints,
898
+ }
899
+ for (const [key, value] of Object.entries(rawDragProps)) {
900
+ if (value !== undefined) dragBag[key] = value
901
+ }
902
+ if (currentDragConfig !== null) {
903
+ const snapAxis = resolvedDragAxes(currentDragConfig).find(
904
+ (axis) => currentDragConfig[axis]?.snap !== undefined,
905
+ )
906
+ if (snapAxis !== undefined) {
907
+ const mapped = snapPointsToDragTransition(currentDragConfig[snapAxis]!.snap!.points)
908
+ dragBag['dragTransition'] = { ...dragTransition, ...mapped }
909
+ }
910
+ }
911
+
912
+ // L4 entry shape gates (severity law; M2.6 lessons — the prop and its accepted shape are
913
+ // named, never a raw TypeError). Valid values forward to motion.div: real motion projects.
914
+ const layoutProps: Record<string, unknown> = {}
915
+ if (layout !== undefined) {
916
+ const validLayout =
917
+ typeof layout === 'boolean' ||
918
+ layout === 'position' ||
919
+ layout === 'size' ||
920
+ layout === 'preserve-aspect'
921
+ if (validLayout) {
922
+ layoutProps['layout'] = layout
923
+ } else {
924
+ const error = new MotionWebRejectionError(
925
+ `${componentId}: "layout" must be a boolean or 'position' | 'size' | 'preserve-aspect' — got ` +
926
+ `${describeValue(layout)} (specs/SPEC-LAYOUT.md)`,
927
+ )
928
+ if (options.mode === 'development') throw error
929
+ options.report(error)
930
+ }
931
+ }
932
+ if (layoutId !== undefined) {
933
+ if (typeof layoutId === 'string' && layoutId.length > 0) {
934
+ layoutProps['layoutId'] = layoutId
935
+ } else {
936
+ const error = new MotionWebRejectionError(
937
+ `${componentId}: "layoutId" must be a non-empty string — got ${describeValue(layoutId)} ` +
938
+ '(specs/SPEC-LAYOUT.md)',
939
+ )
940
+ if (options.mode === 'development') throw error
941
+ options.report(error)
942
+ }
943
+ }
944
+ // REQ-LAYOUT-022: boolean layoutScroll forwards to the pin; invalid values refuse at the boundary.
945
+ if (layoutScroll !== undefined) {
946
+ if (typeof layoutScroll === 'boolean') {
947
+ layoutProps['layoutScroll'] = layoutScroll
948
+ } else {
949
+ const error = new MotionWebRejectionError(
950
+ `${componentId}: "layoutScroll" must be a boolean — got ${describeValue(layoutScroll)} ` +
951
+ '(REQ-LAYOUT-022; specs/SPEC-LAYOUT.md)',
952
+ )
953
+ if (options.mode === 'development') throw error
954
+ options.report(error)
955
+ }
956
+ }
957
+
958
+ // Prop routing, fail-closed (review cycle 1): motion-union keys (the disposition table's
959
+ // domain + style) go through normalization; a key OUR table does not declare but motion.div
960
+ // WOULD interpret (`isValidMotionProp`: variants, layout, whileInView, …) is an undeclared
961
+ // capability and fails loud under the severity law — forwarding it would ship an unratified
962
+ // Motion surface through the shim (the exact leak Codex reproduced). Only genuine DOM
963
+ // attributes (id, data-*, aria-*, DOM handlers) flow to motion.div untouched.
964
+ const motionBag: Record<string, unknown> = {}
965
+ const domAttrs: Record<string, unknown> = {}
966
+ for (const [key, value] of Object.entries(rest)) {
967
+ if (value === undefined) continue
968
+ if (key === 'style' || Object.hasOwn(DISPOSITIONS, key)) {
969
+ // The drag family (incl. dragSnapPoints) is destructured out of `rest` above and mapped via
970
+ // dragBag over core's verdict — it never reaches this loop; its disposition row exists purely
971
+ // for the compile-time exhaustiveness guard (REQ-WEB-012/013), not runtime routing.
972
+ motionBag[key] = value
973
+ } else if (DECLARED_COMPONENT_PROPS.has(key)) {
974
+ domAttrs[key] = value // declared lifecycle plumbing — forwarded to motion as-is
975
+ } else if (isValidMotionProp(key)) {
976
+ // REQ-WEB-020: pin-owned additions bypass the universal/native validation lanes and reach
977
+ // motion/react unchanged. The pin's deliberately prefix-wide predicate remains normative.
978
+ domAttrs[key] = value
979
+ } else {
980
+ domAttrs[key] = value
981
+ }
982
+ }
983
+ // Image element-specific `src` (REQ-API-034(e)): required non-empty string; alt is optional
984
+ // and passes through as a DOM attribute. Missing/invalid refuses only the prop.
985
+ if (role === 'image') {
986
+ const src = domAttrs['src'] ?? (rest as Record<string, unknown>)['src']
987
+ if (typeof src !== 'string' || src.length === 0) {
988
+ const error = new MotionWebRejectionError(
989
+ `${componentId}: "src" is required and must be a non-empty string — got ` +
990
+ `${describeValue(src)} (REQ-API-034).`,
991
+ )
992
+ if (options.mode === 'development') throw error
993
+ options.report(error)
994
+ delete domAttrs['src']
995
+ }
996
+ }
997
+ // R7 (REQ-API-032): variants/inherit and the label forms EXECUTE on this engine —
998
+ // motion/react owns resolution, propagation, and the inherit switch (REQ-WEB-011: the
999
+ // shim adds validation, never animation semantics). This boundary gates VALIDITY at
1000
+ // core depth; a refused unit never reaches motion (cross-engine sanitization parity:
1001
+ // the native entry's controller never sees a refused unit either).
1002
+ // Capture and validate the element transition before its target consumers. The resulting
1003
+ // accepted local transition is what motion/react receives on the element. Keyframe
1004
+ // cardinality validation also sees the nearest MotionConfig default when local is omitted
1005
+ // (native MotionView effectiveElementTransition; REQ-API-033 same-shape law).
1006
+ // tww0y1: distinguish "local omitted" from "local present but refused" — only omission
1007
+ // inherits config for validation; a refused local must not fall back to the parent config
1008
+ // (native gates the chosen effective transition, which is raw local when present).
1009
+ const gatedTransition =
1010
+ transition !== undefined
1011
+ ? gateTransitionWithSeverity(transition, options, componentId)
1012
+ : undefined
1013
+ // Motion owns transition selection and keyframe timing on web. The universal target validator
1014
+ // therefore receives no native timing policy.
1015
+ const validationTransition = undefined
1016
+ const targetProps: Record<string, unknown> = {}
1017
+ const capturedInitialLabels = isLabelForm(initial)
1018
+ ? captureLabelFormWithSeverity('initial', initial, options, componentId)
1019
+ : undefined
1020
+ const capturedAnimateLabels = isLabelForm(animate)
1021
+ ? captureLabelFormWithSeverity('animate', animate, options, componentId)
1022
+ : undefined
1023
+ const capturedExitLabels = isLabelForm(exit)
1024
+ ? captureLabelFormWithSeverity('exit', exit, options, componentId)
1025
+ : undefined
1026
+ const rawTapLabels = motionBag['whileTap']
1027
+ const rawDragLabels = motionBag['whileDrag']
1028
+ const capturedTapLabels = isLabelForm(rawTapLabels)
1029
+ ? captureLabelFormWithSeverity('whileTap', rawTapLabels, options, componentId)
1030
+ : undefined
1031
+ const capturedDragLabels = isLabelForm(rawDragLabels)
1032
+ ? captureLabelFormWithSeverity('whileDrag', rawDragLabels, options, componentId)
1033
+ : undefined
1034
+ if (isLabelForm(rawTapLabels)) {
1035
+ if (capturedTapLabels === undefined) delete motionBag['whileTap']
1036
+ else motionBag['whileTap'] = capturedTapLabels
1037
+ }
1038
+ if (isLabelForm(rawDragLabels)) {
1039
+ if (capturedDragLabels === undefined) delete motionBag['whileDrag']
1040
+ else motionBag['whileDrag'] = capturedDragLabels
1041
+ }
1042
+ const localInitialLabels = labelNames(capturedInitialLabels)
1043
+ const localAnimateLabels = labelNames(capturedAnimateLabels)
1044
+ const localExitLabels = labelNames(capturedExitLabels)
1045
+ const localTapLabels = labelNames(capturedTapLabels)
1046
+ const localDragLabels = labelNames(capturedDragLabels)
1047
+ // Match Motion's pinned isControllingVariants law: ANY local label supplier owns the
1048
+ // variant selection context as a unit. A child that supplies whileTap must not also validate
1049
+ // a parent's animate label against its own transition, and inherit={false} suppresses the
1050
+ // entire inherited mount selection.
1051
+ const controlsVariants =
1052
+ localInitialLabels !== undefined ||
1053
+ localAnimateLabels !== undefined ||
1054
+ localExitLabels !== undefined ||
1055
+ localTapLabels !== undefined ||
1056
+ localDragLabels !== undefined
1057
+ const acceptsInheritedSelections = inherit !== false && !controlsVariants
1058
+ // `initial` has one additional supplier rule: an object or `false` is a local first-paint
1059
+ // supplier for THIS node, so an inherited initial label is not selected here. Neither form is a
1060
+ // controlling label host, however, so the ancestor selection still propagates to descendants.
1061
+ const hasLocalNonLabelInitialSupplier =
1062
+ initial === false || (initial !== undefined && !isLabelForm(initial))
1063
+ // Motion's controlling rule is label-form only. A local object target executes alongside
1064
+ // inherited label selections, so raw prop presence cannot erase those selections from this
1065
+ // validation-only context. An accepted local label remains the one controlling replacement.
1066
+ const effectiveInitialLabels =
1067
+ localInitialLabels ??
1068
+ (hasLocalNonLabelInitialSupplier || !acceptsInheritedSelections
1069
+ ? undefined
1070
+ : inheritedTimingSelections.initial)
1071
+ const effectiveAnimateLabels =
1072
+ localAnimateLabels ??
1073
+ (!acceptsInheritedSelections ? undefined : inheritedTimingSelections.animate)
1074
+ const effectiveExitLabels =
1075
+ localExitLabels ?? (!acceptsInheritedSelections ? undefined : inheritedTimingSelections.exit)
1076
+ const effectiveTapLabels =
1077
+ localTapLabels ??
1078
+ (!acceptsInheritedSelections ? undefined : inheritedTimingSelections.whileTap)
1079
+ const effectiveDragLabels =
1080
+ localDragLabels ??
1081
+ (!acceptsInheritedSelections ? undefined : inheritedTimingSelections.whileDrag)
1082
+ const selectedTimingLabels = [
1083
+ ...(effectiveAnimateLabels ?? []),
1084
+ ...(effectiveExitLabels ?? []),
1085
+ ...(effectiveTapLabels ?? []),
1086
+ ...(effectiveDragLabels ?? []),
1087
+ ]
1088
+ let timingFilteredLabels: ReadonlySet<string> = new Set()
1089
+ let initialFilteredLabels: ReadonlySet<string> = new Set()
1090
+ let initialProjectionVariants: VariantsDictionary | undefined
1091
+ if (variants !== undefined) {
1092
+ const originalGatedVariants = gateVariantsWithSeverity(variants, options, componentId)
1093
+ const timingGatedVariants = gateVariantTimingConsumers(
1094
+ originalGatedVariants,
1095
+ selectedTimingLabels,
1096
+ effectiveInitialLabels === false ? undefined : effectiveInitialLabels,
1097
+ validationTransition,
1098
+ options,
1099
+ componentId,
1100
+ )
1101
+ timingFilteredLabels = timingGatedVariants.filteredLabels
1102
+ initialFilteredLabels = timingGatedVariants.initialFilteredLabels
1103
+ const gatedVariants = timingGatedVariants.dictionary
1104
+ initialProjectionVariants = timingGatedVariants.initialProjection
1105
+ if (gatedVariants !== undefined) targetProps['variants'] = gatedVariants
1106
+ }
1107
+ const normalized = normalizeComponentProps(
1108
+ motionBag,
1109
+ options,
1110
+ validationTransition,
1111
+ componentId,
1112
+ )
1113
+ const mappedTap =
1114
+ motionBag['whileTap'] === undefined ? undefined : mappedLabels(localTapLabels, new Map())
1115
+ const mappedDrag =
1116
+ motionBag['whileDrag'] === undefined ? undefined : mappedLabels(localDragLabels, new Map())
1117
+ if (mappedTap !== undefined) normalized.props['whileTap'] = mappedTap
1118
+ if (mappedDrag !== undefined) normalized.props['whileDrag'] = mappedDrag
1119
+ if (inherit !== undefined) {
1120
+ const gatedInherit = gateInheritWithSeverity(inherit, options, componentId)
1121
+ if (gatedInherit !== undefined) targetProps['inherit'] = gatedInherit
1122
+ }
1123
+ if (initial !== undefined) {
1124
+ // The pin's public MotionProps types do not advertise direct resolver functions, but its
1125
+ // runtime resolveVariantFromProps path executes them when supplied through an untyped/cast
1126
+ // boundary. Preserve that exact type/runtime asymmetry: a function reaches motion/react by
1127
+ // identity and never enters Native Motion's object-target validator (REQ-API-056).
1128
+ if (typeof (initial as unknown) === 'function') {
1129
+ targetProps['initial'] = initial
1130
+ } else if (initial === false) {
1131
+ targetProps['initial'] = false
1132
+ } else if (isLabelForm(initial)) {
1133
+ if (capturedInitialLabels !== undefined) {
1134
+ // `initial` is a first-paint supplier, not a timing consumer. It deliberately
1135
+ // uses its own scalar-gated projection when animate needs a sanitized sibling.
1136
+ targetProps['initial'] =
1137
+ localInitialLabels?.some(
1138
+ (label) => timingFilteredLabels.has(label) || initialFilteredLabels.has(label),
1139
+ ) === true
1140
+ ? resolveInitialOverlay(localInitialLabels, initialProjectionVariants)
1141
+ : mappedLabels(localInitialLabels, new Map())
1142
+ }
1143
+ } else {
1144
+ // `initial` paints a starting state. It does not execute the element's keyframe tween, so a
1145
+ // scalar initial value must not be rejected merely because animate owns timing arrays.
1146
+ targetProps['initial'] = validateWithSeverityResult(
1147
+ initial,
1148
+ 'initial',
1149
+ options,
1150
+ undefined,
1151
+ true,
1152
+ componentId,
1153
+ ).target
1154
+ }
1155
+ } else if (
1156
+ effectiveInitialLabels !== undefined &&
1157
+ effectiveInitialLabels !== false &&
1158
+ effectiveInitialLabels.some(
1159
+ (label) => timingFilteredLabels.has(label) || initialFilteredLabels.has(label),
1160
+ )
1161
+ ) {
1162
+ // An inherited initial label remains Motion's propagation concern. This object overlay is
1163
+ // the narrow production repair for a timing-sanitised shared label: it preserves the
1164
+ // legal first paint from the immutable scalar-gated dictionary without making the child a
1165
+ // local label controller.
1166
+ targetProps['initial'] = resolveInitialOverlay(
1167
+ effectiveInitialLabels,
1168
+ initialProjectionVariants,
1169
+ )
1170
+ }
1171
+ if (animate !== undefined) {
1172
+ if (typeof animate === 'boolean') {
1173
+ // U7h (REQ-API-057): forward the pin's boolean skip; do not validate as a target.
1174
+ targetProps['animate'] = animate
1175
+ } else if (typeof (animate as unknown) === 'function') {
1176
+ targetProps['animate'] = animate
1177
+ } else if (isLabelForm(animate)) {
1178
+ if (capturedAnimateLabels !== undefined) {
1179
+ targetProps['animate'] = mappedLabels(localAnimateLabels, new Map())
1180
+ }
1181
+ } else {
1182
+ const result = validateWithSeverityResult(
1183
+ animate,
1184
+ 'animate',
1185
+ options,
1186
+ validationTransition,
1187
+ false,
1188
+ componentId,
1189
+ )
1190
+ targetProps['animate'] = result.target
1191
+ }
1192
+ }
1193
+ if (exit !== undefined) {
1194
+ if (typeof (exit as unknown) === 'function') {
1195
+ targetProps['exit'] = exit
1196
+ } else if (isLabelForm(exit)) {
1197
+ if (capturedExitLabels !== undefined) {
1198
+ targetProps['exit'] = mappedLabels(localExitLabels, new Map())
1199
+ }
1200
+ } else {
1201
+ const result = validateWithSeverityResult(
1202
+ exit,
1203
+ 'exit',
1204
+ options,
1205
+ validationTransition,
1206
+ false,
1207
+ componentId,
1208
+ )
1209
+ targetProps['exit'] = result.target
1210
+ }
1211
+ }
1212
+ if (gatedTransition !== undefined) {
1213
+ targetProps['transition'] = gatedTransition
1214
+ }
1215
+
1216
+ // `inherit={false}` gates only this node's mount selection. It does not make the node a
1217
+ // controlling label host, so descendants must still receive the outer accepted labels. A
1218
+ // genuine local label host, by contrast, resets the propagated batch as one unit.
1219
+ const suppliedTimingSelections: VariantTimingSelections = {
1220
+ initial:
1221
+ localInitialLabels ?? (controlsVariants ? undefined : inheritedTimingSelections.initial),
1222
+ animate:
1223
+ localAnimateLabels ?? (controlsVariants ? undefined : inheritedTimingSelections.animate),
1224
+ exit: localExitLabels ?? (controlsVariants ? undefined : inheritedTimingSelections.exit),
1225
+ whileTap:
1226
+ localTapLabels ?? (controlsVariants ? undefined : inheritedTimingSelections.whileTap),
1227
+ whileDrag:
1228
+ localDragLabels ?? (controlsVariants ? undefined : inheritedTimingSelections.whileDrag),
1229
+ }
1230
+ // R15 D6 (REQ-API-047): pan observation callbacks — pin owns the session without drag.
1231
+ const panProps: Record<string, unknown> = {}
1232
+ if (onPanSessionStart !== undefined) panProps['onPanSessionStart'] = onPanSessionStart
1233
+ if (onPanStart !== undefined) panProps['onPanStart'] = onPanStart
1234
+ if (onPan !== undefined) panProps['onPan'] = onPan
1235
+ if (onPanEnd !== undefined) panProps['onPanEnd'] = onPanEnd
1236
+ // T17: the gated template forwards to motion/react, which owns the per-frame call.
1237
+ const templateProps: Record<string, unknown> = {}
1238
+ if (gatedTransformTemplate !== undefined)
1239
+ templateProps['transformTemplate'] = gatedTransformTemplate
1240
+ // T24 B1: custom forwards untouched — motion/react resolves dynamic variants with it.
1241
+ if (custom !== undefined) templateProps['custom'] = custom
1242
+ const host: ReactNode = createElement(
1243
+ element as never,
1244
+ {
1245
+ ref,
1246
+ ...domAttrs,
1247
+ ...targetProps,
1248
+ ...layoutProps,
1249
+ ...normalized.props,
1250
+ ...dragBag,
1251
+ ...panProps,
1252
+ ...templateProps,
1253
+ } as never,
1254
+ children,
1255
+ )
1256
+ return createElement(VariantTimingContext.Provider, { value: suppliedTimingSelections }, host)
1257
+ })
1258
+
1259
+ return forwardRef<ComponentRef<Host>, MotionHostProps<Host>>(function ViewBoundary(props, ref) {
1260
+ const textNestingBoundary = useContext(TextNestingContext)
1261
+ const nestedViewRefusalReportedRef = useRef(false)
1262
+ if (!textHost && textNestingBoundary !== null) {
1263
+ const error = new MotionWebRejectionError(
1264
+ 'Motion.Text cannot contain a Motion.View child; text hosts accept only text-compatible ' +
1265
+ 'ReactNode children (REQ-API-034).',
1266
+ )
1267
+ if (textNestingBoundary.mode === 'development') throw error
1268
+ if (!nestedViewRefusalReportedRef.current) {
1269
+ nestedViewRefusalReportedRef.current = true
1270
+ textNestingBoundary.report(error)
1271
+ }
1272
+ return null
1273
+ }
1274
+ const host = createElement(HostViewRuntime, { ...props, ref } as never)
1275
+ return textHost ? createElement(TextNestingContext.Provider, { value: options }, host) : host
1276
+ })
1277
+ }
1278
+
1279
+ // The one web host seam. The legacy one-argument test factory stays behaviorally identical;
1280
+ // alternate motion/react hosts must provide the normalization channel explicitly.
1281
+ export function createView(options: NormalizeOptions): MotionHostView<typeof motion.div>
1282
+ export function createView<Host extends MotionHostElement>(
1283
+ element: Host,
1284
+ options: NormalizeOptions,
1285
+ ): MotionHostView<Host>
1286
+ export function createView<Host extends MotionHostElement>(
1287
+ elementOrOptions: Host | NormalizeOptions,
1288
+ maybeOptions?: NormalizeOptions,
1289
+ ): MotionHostView<Host> | MotionHostView<typeof motion.div> {
1290
+ if (maybeOptions === undefined)
1291
+ return createViewForHost(motion.div, elementOrOptions as NormalizeOptions)
1292
+ return createViewForHost(elementOrOptions as Host, maybeOptions)
1293
+ }
1294
+
1295
+ /* The default error channel: loud in the console, never swallowed. */
1296
+ function consoleReporter(error: Error): void {
1297
+ console.error(error)
1298
+ }
1299
+
1300
+ export const View = createView(motion.div, { mode: ambientMode(), report: consoleReporter })
1301
+
1302
+ const TEXT_OPTIONS: NormalizeOptions = {
1303
+ // Unlike the named test factory, the public Text component cannot receive explicit options.
1304
+ // Keep its severity decision render-fresh so the production refusal path remains testable and
1305
+ // matches the public View's ambient contract.
1306
+ get mode() {
1307
+ return ambientMode()
1308
+ },
1309
+ report: consoleReporter,
1310
+ }
1311
+ const ParagraphText = createViewForHost(motion.p, TEXT_OPTIONS, 'text')
1312
+ const InlineText = createViewForHost(motion.span, TEXT_OPTIONS, 'text')
1313
+
1314
+ // REQ-API-034 law (b): the catalog ports motion.p with the default block host and motion.span
1315
+ // with `as="span"`. Native accepts the selector as a semantic no-op because RN Text has no DOM
1316
+ // display-mode distinction; keeping it in the universal type makes a catalog port typecheck on
1317
+ // both entries.
1318
+ export type TextProps = Omit<MotionHostProps<typeof motion.p>, 'as'> & {
1319
+ readonly as?: 'p' | 'span'
1320
+ }
1321
+ export const Text = forwardRef<HTMLElement, TextProps>(function Text({ as, ...props }, ref) {
1322
+ const nestedInsideText = useContext(TextNestingContext) !== null
1323
+ let selectedAs: 'p' | 'span' = nestedInsideText ? 'span' : 'p'
1324
+ if (as === 'span' || (!nestedInsideText && as === 'p')) selectedAs = as
1325
+ // A paragraph nested in Text produces an invalid DOM tree. The nesting context is the rendered
1326
+ // ancestor, so explicit `p` is valid at the root but is an invalid selector below Text.
1327
+ if ((as !== undefined && as !== 'p' && as !== 'span') || (nestedInsideText && as === 'p')) {
1328
+ const error = new MotionWebRejectionError(
1329
+ `<Text> "as" must be "p" or "span" at the root and "span" inside another Text host — got ${describeValue(as)} (REQ-API-034).`,
1330
+ )
1331
+ if (ambientMode() === 'development') throw error
1332
+ consoleReporter(error)
1333
+ // Production refuses only the invalid selector; nested Text always falls back inline.
1334
+ }
1335
+ const Host = selectedAs === 'span' ? InlineText : ParagraphText
1336
+ return createElement(Host, { ...props, ref } as never)
1337
+ })
1338
+
1339
+ const IMAGE_OPTIONS: NormalizeOptions = {
1340
+ get mode() {
1341
+ return ambientMode()
1342
+ },
1343
+ report: consoleReporter,
1344
+ }
1345
+
1346
+ // REQ-API-034 law (c): Image covers motion.img. `src`/`alt` are ordinary DOM attributes; the
1347
+ // Motion props contract matches View; children are refused at host render.
1348
+ export type ImageProps = Omit<MotionHostProps<typeof motion.img>, 'children'> & {
1349
+ readonly children?: never
1350
+ }
1351
+ export const Image = createViewForHost(
1352
+ motion.img,
1353
+ IMAGE_OPTIONS,
1354
+ 'image',
1355
+ ) as ForwardRefExoticComponent<PropsWithoutRef<ImageProps> & RefAttributes<HTMLImageElement>>
1356
+ Image.displayName = 'Motion.Image'