@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.
@@ -0,0 +1,324 @@
1
+ // The component-prop normalization pipeline (REQ-WEB-012/013/015): pure, total, side-effect-
2
+ // free. Severity is a PARAMETER (ratified 2026-07-06: dev throws; production reports through the
3
+ // error channel and refuses the offending property) — the React layer resolves the ambient mode;
4
+ // this module never reads the environment.
5
+ import {
6
+ describeValue,
7
+ captureTransition,
8
+ hostCapabilities,
9
+ validateTargetSnapshot,
10
+ UNIVERSAL_SUBSET,
11
+ type Target,
12
+ type Transition,
13
+ } from '@unrulysystems/native-motion-core'
14
+ import {
15
+ captureBoundedArray,
16
+ capturedArrayDescription,
17
+ } from '@unrulysystems/native-motion-core/internal-driver'
18
+ import { dispositionFor } from './disposition'
19
+ import {
20
+ MotionWebIncompatibleError,
21
+ MotionWebRejectionError,
22
+ type MotionWebError,
23
+ type WebErrorMode,
24
+ type WebErrorReporter,
25
+ } from './errors'
26
+ import { mapWebTransitionAliases, type WebTransition } from './webTransition'
27
+
28
+ export interface NormalizeOptions {
29
+ readonly mode: WebErrorMode
30
+ readonly report: WebErrorReporter
31
+ }
32
+
33
+ export type MotionComponentId = '<View>' | '<Text>' | '<Image>'
34
+
35
+ export interface NormalizedComponentProps {
36
+ // The motion/react-shaped prop bag: refused props removed, extensions mapped, style pinned.
37
+ readonly props: Record<string, unknown>
38
+ // Prop paths refused in production mode (dev throws instead) — reported, never silent.
39
+ readonly refused: readonly string[]
40
+ /** Gesture members that reached an element fallback before severity filtering. */
41
+ readonly attemptedElementTransitionOwners: readonly string[]
42
+ }
43
+
44
+ // The host boundary must validate exactly the record it hands to motion/react. Plain transitions are
45
+ // captured through CORE's bounded timing snapshot; malformed outer shapes stay raw so core can issue its
46
+ // existing typed refusal. Getter faults deliberately escape untouched.
47
+ export function captureTransitionSnapshot(value: unknown): unknown {
48
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
49
+ const prototype = Object.getPrototypeOf(value)
50
+ if (prototype === Object.prototype || prototype === null)
51
+ return captureTransition(value as Transition)
52
+ }
53
+ return value
54
+ }
55
+
56
+ const BOX_SIZING_PIN = 'border-box'
57
+
58
+ function rejectionMessage(key: string, reason: string): string {
59
+ switch (reason) {
60
+ case 'deferred-spike-scope':
61
+ return (
62
+ `"${key}" is deferred from the first-spike universal subset (ratified 2026-07-06) — ` +
63
+ 'it fails loudly on every engine until its own rung lands'
64
+ )
65
+ case 'outside-universal-target':
66
+ return (
67
+ `"${key}" is a web-only registry member outside the universal Target — the universal ` +
68
+ 'surface refuses it on every engine (core validateTarget parity)'
69
+ )
70
+ default:
71
+ return `"${key}" requires the native platform and has no faithful web mapping (REQ-WEB-015)`
72
+ }
73
+ }
74
+
75
+ const WEB_HOST = hostCapabilities('web', ['universal'])
76
+
77
+ /**
78
+ * R7 (REQ-API-032 law f): the label-form validity gate the web boundary shares across every
79
+ * label-carrying prop (initial/animate/exit at the View, whileTap/whileDrag here). A string
80
+ * is a label; an array must be string-only and refuses naming the offending INDEX — the
81
+ * same depth the native gate enforces. Resolution and propagation are motion/react's
82
+ * (REQ-WEB-011); validity is this boundary's. Returns whether the definition is accepted
83
+ * (development throws instead of returning false).
84
+ */
85
+ export function gateLabelFormWithSeverity(
86
+ propName: string,
87
+ definition: string | readonly unknown[],
88
+ options: NormalizeOptions,
89
+ componentId: MotionComponentId = '<View>',
90
+ ): boolean {
91
+ return captureLabelFormWithSeverity(propName, definition, options, componentId) !== undefined
92
+ }
93
+
94
+ /** Capture a label form exactly once and return the immutable value Motion receives. */
95
+ export function captureLabelFormWithSeverity(
96
+ propName: string,
97
+ definition: string | readonly unknown[],
98
+ options: NormalizeOptions,
99
+ componentId: MotionComponentId = '<View>',
100
+ ): string | readonly string[] | undefined {
101
+ if (typeof definition === 'string') return definition
102
+ const capturedDefinition = captureBoundedArray(definition)
103
+ if (capturedDefinition.kind !== 'captured') {
104
+ const error = new MotionWebRejectionError(
105
+ `${componentId}: "${propName}" label array must have a safe length — received ${capturedArrayDescription(capturedDefinition)} ` +
106
+ '(REQ-API-032 law f)',
107
+ )
108
+ if (options.mode === 'development') throw error
109
+ options.report(error)
110
+ return undefined
111
+ }
112
+ const length = capturedDefinition.values.length
113
+ const captured: string[] = []
114
+ captured.length = length
115
+ for (let index = 0; index < length; index++) {
116
+ const member = Object.hasOwn(capturedDefinition.values, index)
117
+ ? capturedDefinition.values[index]
118
+ : undefined
119
+ if (typeof member === 'string') {
120
+ captured[index] = member
121
+ continue
122
+ }
123
+ // describeValue, never JSON.stringify (M3 r1 major cdc42f01c118): a BigInt member
124
+ // would crash the FORMATTER itself — a raw serialize TypeError replacing the typed
125
+ // law-(f) refusal in development and escaping unreported in production.
126
+ const error = new MotionWebRejectionError(
127
+ `${componentId}: "${propName}" label array member at index ${index} must be a string — received ` +
128
+ `${describeValue(member)} (REQ-API-032 law f; the label array is ` +
129
+ 'string-only)',
130
+ )
131
+ if (options.mode === 'development') throw error
132
+ options.report(error)
133
+ return undefined
134
+ }
135
+ return Object.freeze(captured)
136
+ }
137
+
138
+ export function normalizeComponentProps(
139
+ input: Readonly<Record<string, unknown>>,
140
+ options: NormalizeOptions,
141
+ _fallbackTransition: WebTransition | undefined = undefined,
142
+ componentId: MotionComponentId = '<View>',
143
+ ): NormalizedComponentProps {
144
+ const refused: string[] = []
145
+ const attemptedElementTransitionOwners: string[] = []
146
+ // Dev throws; production reports and records the refusal. Returns true when the prop must be
147
+ // dropped from the output (production path).
148
+ const refuse = (error: MotionWebError, propPath: string): void => {
149
+ if (options.mode === 'development') throw error
150
+ options.report(error)
151
+ refused.push(propPath)
152
+ }
153
+
154
+ const props: Record<string, unknown> = {}
155
+
156
+ // The drag family (drag/dragConstraints/dragElastic/dragSnapPoints + callbacks) is NOT routed
157
+ // here (FLAG 5a): the View owns it — core resolveDragConfig is the single cross-engine verdict,
158
+ // and the View maps dragSnapPoints to dragTransition via nearestSnap over core-validated points.
159
+ // This lane handles the animatable-target + gesture-callback props that pass straight to motion.
160
+ for (const [key, value] of Object.entries(input)) {
161
+ if (key === 'style') continue // handled below: the pin composes with user style
162
+ const disposition = dispositionFor(key)
163
+ switch (disposition.kind) {
164
+ case 'pass-through':
165
+ // The tap trio's callbacks are gated for SHAPE before motion sees them (R5 review r6
166
+ // major 36 — the shared severity law; native's boundary gates the same way): a
167
+ // present-but-non-function member is a typed refusal, never a raw crash inside
168
+ // motion's recognizer dispatch.
169
+ // The gesture-state pair (R6 object form; R7 adds the LABEL form, REQ-API-032):
170
+ // labels validate string-only (naming the index) and PASS to motion — motion
171
+ // resolves them against its own variants context (law b's web leg; REQ-WEB-011).
172
+ // Object-form depth mirrors the NATIVE gate (r1 major 6) — null/non-object shapes
173
+ // refuse typed, the embedded transition splits, and the remaining target validates
174
+ // through core exactly like animate.
175
+ if ((key === 'whileTap' || key === 'whileDrag') && value !== undefined) {
176
+ if (typeof value === 'string' || Array.isArray(value)) {
177
+ const labels = captureLabelFormWithSeverity(key, value, options, componentId)
178
+ if (labels !== undefined) props[key] = labels
179
+ break
180
+ }
181
+ const proto =
182
+ typeof value === 'object' && value !== null ? Object.getPrototypeOf(value) : undefined
183
+ if (
184
+ typeof value !== 'object' ||
185
+ value === null ||
186
+ (proto !== Object.prototype && proto !== null)
187
+ ) {
188
+ // PLAIN targets only (r2 major 13): a prototype-carrying object (Date, class
189
+ // instance) destructures to an empty target and would forward silently.
190
+ refuse(
191
+ new MotionWebRejectionError(
192
+ `${componentId}: "${key}" must be a plain target object, received ${value === null ? 'null' : typeof value} (REQ-API-031).`,
193
+ ),
194
+ key,
195
+ )
196
+ break
197
+ }
198
+ // Read the embedded transition exactly once, then build the target from the remaining own
199
+ // keys. Destructuring plus Object.entries would invoke an accessor twice and could validate
200
+ // one transition while motion receives another.
201
+ const stateDefinition = value as Record<string, unknown>
202
+ const rawStateTransition = stateDefinition['transition']
203
+ const stateTarget: Record<string, unknown> = Object.create(null)
204
+ for (const memberKey of Object.keys(stateDefinition)) {
205
+ if (memberKey !== 'transition') stateTarget[memberKey] = stateDefinition[memberKey]
206
+ }
207
+ let stateTransition: unknown = rawStateTransition
208
+ if (rawStateTransition !== undefined) {
209
+ try {
210
+ stateTransition = mapWebTransitionAliases(
211
+ rawStateTransition as WebTransition,
212
+ `${componentId}.${key}.transition`,
213
+ )
214
+ } catch (error) {
215
+ if (!(error instanceof MotionWebRejectionError)) throw error
216
+ refuse(error, `${key}.transition`)
217
+ stateTransition = undefined
218
+ }
219
+ }
220
+ // PER-KEY severity (the shared law, r2 major 13): each member validates alone —
221
+ // development throws on the first offender; production reports it and keeps the
222
+ // valid members.
223
+ const acceptedTarget: Record<string, unknown> = {}
224
+ for (const [memberKey, memberValue] of Object.entries(stateTarget)) {
225
+ // REQ-WEB-019: Motion owns intrinsic-web value grammar and timing. Native Motion's
226
+ // portable complex-value validator deliberately refuses these keys, so applying it
227
+ // here would project the native gap onto the web engine.
228
+ if (UNIVERSAL_SUBSET.get(memberKey)?.universality === 'web-only') {
229
+ acceptedTarget[memberKey] = memberValue
230
+ continue
231
+ }
232
+ // validateTargetSnapshot materializes the member's keyframe ARRAY ONCE before validation
233
+ // and returns that snapshot; the ACCEPTED value is the snapshot, never the caller's raw
234
+ // array (one read, one truth — R8, review major 23), so motion never re-reads an accessor.
235
+ const { refusal: memberRefusal, target: memberSnapshot } = validateTargetSnapshot(
236
+ { [memberKey]: memberValue } as Target,
237
+ WEB_HOST,
238
+ {
239
+ componentId,
240
+ },
241
+ )
242
+ if (memberRefusal !== null) {
243
+ refuse(
244
+ new MotionWebRejectionError(memberRefusal.message, { cause: memberRefusal }),
245
+ `${key}.${memberKey}`,
246
+ )
247
+ continue
248
+ }
249
+ const acceptedValue = (memberSnapshot as Record<string, unknown>)[memberKey]
250
+ acceptedTarget[memberKey] = acceptedValue
251
+ }
252
+ props[key] =
253
+ stateTransition === undefined
254
+ ? acceptedTarget
255
+ : { ...acceptedTarget, transition: stateTransition }
256
+ break
257
+ }
258
+ if (
259
+ (key === 'onTap' || key === 'onTapStart' || key === 'onTapCancel') &&
260
+ value !== undefined &&
261
+ typeof value !== 'function'
262
+ ) {
263
+ refuse(
264
+ new MotionWebRejectionError(
265
+ `${componentId}: "${key}" must be a function, received ${typeof value} — the tap callbacks are ` +
266
+ 'the pinned (event, info) handlers (REQ-API-030).',
267
+ ),
268
+ key,
269
+ )
270
+ break
271
+ }
272
+ props[key] = value
273
+ break
274
+ case 'rejected':
275
+ refuse(
276
+ new MotionWebRejectionError(
277
+ `${componentId}: ${rejectionMessage(key, disposition.reason)}`,
278
+ ),
279
+ key,
280
+ )
281
+ break
282
+ case 'normalized':
283
+ // No prop declares 'normalized' yet (REQ-WEB-012 reserves the kind); reaching it means
284
+ // the table gained a row without a pipeline branch — schema drift, fail loud.
285
+ throw new MotionWebIncompatibleError(
286
+ `prop "${key}" declares the 'normalized' disposition but no normalizer is wired`,
287
+ )
288
+ case 'extension-mapped':
289
+ // The View owns the runtime extension mapping (dragSnapPoints → dragTransition, REQ-WEB-013):
290
+ // it destructures the extension family out BEFORE this lane runs. Reaching here means routing
291
+ // dropped it — schema/routing drift, fail loud (never forward an unmapped extension to motion).
292
+ throw new MotionWebIncompatibleError(
293
+ `prop "${key}" is extension-mapped (View-owned, REQ-WEB-013) and must not reach the ` +
294
+ 'normalize lane — routing drift',
295
+ )
296
+ default: {
297
+ const exhaustive: never = disposition
298
+ throw new MotionWebIncompatibleError(`unreachable disposition: ${String(exhaustive)}`)
299
+ }
300
+ }
301
+ }
302
+
303
+ // The ratified border-box pin: RN computes layout as border-box, so the shim pins the same on
304
+ // web or a width animation means a different rendered box per engine. A conflicting user value
305
+ // is refused loudly — the pin always wins, never silently.
306
+ const userStyle = (input['style'] ?? {}) as Readonly<Record<string, unknown>>
307
+ const { boxSizing: userBoxSizing, ...restStyle } = userStyle
308
+ if (userBoxSizing !== undefined && userBoxSizing !== BOX_SIZING_PIN) {
309
+ refuse(
310
+ new MotionWebRejectionError(
311
+ `${componentId}: style.boxSizing: ${describeValue(userBoxSizing)} conflicts with the shim's border-box pin ` +
312
+ '(ratified 2026-07-06): cross-engine layout parity requires border-box',
313
+ ),
314
+ 'style.boxSizing',
315
+ )
316
+ }
317
+ props['style'] = { ...restStyle, boxSizing: BOX_SIZING_PIN }
318
+
319
+ return {
320
+ props,
321
+ refused,
322
+ attemptedElementTransitionOwners: Object.freeze(attemptedElementTransitionOwners),
323
+ }
324
+ }
package/src/snap.ts ADDED
@@ -0,0 +1,82 @@
1
+ // dragSnapPoints → dragTransition.modifyTarget MAPPING (REQ-WEB-013). The public shape is ratified
2
+ // (2026-07-06): ABSOLUTE positions in the drag value's own coordinate space, one array per axis.
3
+ // motion/react's modifyTarget is `(v: number) => number` with NO axis identity (verified against
4
+ // the pinned motion-dom@12.42.2 types).
5
+ //
6
+ // VALIDATION of the drag family (shape verdicts, axis consistency, both-axes/free-drag deferrals)
7
+ // is single-sourced in core (`@unrulysystems/native-motion-core` resolveDragConfig, FLAG 5a) so
8
+ // both engines resolve identical accepted sets. This module is now the web-specific MAPPING only:
9
+ // nearestSnap turns core-validated points into the modifyTarget projection the web View forwards.
10
+ import { MotionWebIncompatibleError } from './errors'
11
+
12
+ export type DragAxis = 'x' | 'y' | true
13
+
14
+ export interface DragSnapPoints {
15
+ readonly x?: readonly number[]
16
+ readonly y?: readonly number[]
17
+ }
18
+
19
+ export interface WebDragTransition {
20
+ readonly modifyTarget: (target: number) => number
21
+ }
22
+
23
+ function describeShape(value: unknown): string {
24
+ if (value === null) return 'null'
25
+ if (Array.isArray(value)) return 'an array'
26
+ return typeof value
27
+ }
28
+
29
+ // A defensive guard on nearestSnap's own public input (it is independently callable): a non-array
30
+ // or non-finite member is a TYPED failure, never a raw TypeError. Core has already validated the
31
+ // points that reach the web View, so this is defense-in-depth on the standalone mapping.
32
+ function invalidSnapSet(
33
+ points: readonly number[],
34
+ label: string,
35
+ ): MotionWebIncompatibleError | null {
36
+ if (!Array.isArray(points)) {
37
+ return new MotionWebIncompatibleError(
38
+ `${label} must be an array of finite numbers, got ${describeShape(points)}`,
39
+ )
40
+ }
41
+ if (points.length === 0) {
42
+ return new MotionWebIncompatibleError(`${label} must declare at least one snap point`)
43
+ }
44
+ for (const point of points) {
45
+ if (typeof point !== 'number' || !Number.isFinite(point)) {
46
+ return new MotionWebIncompatibleError(
47
+ `${label} must contain only finite numbers, got ${String(point)}`,
48
+ )
49
+ }
50
+ }
51
+ return null
52
+ }
53
+
54
+ export function nearestSnap(snapPoints: readonly number[]): (target: number) => number {
55
+ const invalid = invalidSnapSet(snapPoints, 'snap points')
56
+ if (invalid) throw invalid
57
+ // Ascending order + strict improvement makes exact-midpoint ties resolve toward the SMALLER
58
+ // snap value, so projection is deterministic and independent of declaration order.
59
+ const points = [...snapPoints].sort((a, b) => a - b)
60
+ return (target: number) => {
61
+ if (!Number.isFinite(target)) {
62
+ throw new MotionWebIncompatibleError(
63
+ `snap projection requires a finite target, got ${String(target)}`,
64
+ )
65
+ }
66
+ let best = Number.NaN
67
+ let bestDistance = Number.POSITIVE_INFINITY
68
+ for (const point of points) {
69
+ const distance = Math.abs(target - point)
70
+ if (distance < bestDistance) {
71
+ best = point
72
+ bestDistance = distance
73
+ }
74
+ }
75
+ return best
76
+ }
77
+ }
78
+
79
+ // The web mapping: core-validated points → the dragTransition motion.div receives (REQ-WEB-013).
80
+ export function snapPointsToDragTransition(points: readonly number[]): WebDragTransition {
81
+ return { modifyTarget: nearestSnap(points) }
82
+ }
@@ -0,0 +1,18 @@
1
+ 'use client'
2
+ // REQ-API-039 — web useAnimate entry: thin re-export of the pin's useAnimate from motion/react
3
+ // (REQ-WEB-011). No custom web animation engine; scope + playback are the pin's.
4
+ //
5
+ // Public return type is a SHALLOW pair (not pin generics) so dual-entry type-identity does not
6
+ // hang tsgo on motion's scoped-animate overload surface.
7
+
8
+ import { useAnimate as pinUseAnimate } from 'motion/react'
9
+ import type { AnimationPlaybackControls } from './animate'
10
+
11
+ export interface AnimationScope<T = unknown> {
12
+ current: T | null
13
+ animations: AnimationPlaybackControls[]
14
+ }
15
+
16
+ export type UseAnimateReturn = ReturnType<typeof pinUseAnimate>
17
+
18
+ export const useAnimate = pinUseAnimate
@@ -0,0 +1,12 @@
1
+ 'use client'
2
+ // REQ-API-040 — useCycle web entry: thin re-export of the pin's useCycle from motion/react
3
+ // (REQ-WEB-011). Zero new semantics; native owns the React-state twin for RN.
4
+ // Cycle/CycleState type names are declared here for dual-entry type-export parity (native
5
+ // exports the same names); runtime is solely the pin hook.
6
+
7
+ import { useCycle as pinUseCycle } from 'motion/react'
8
+
9
+ export type Cycle = (i?: number) => void
10
+ export type CycleState<T> = [T, Cycle]
11
+
12
+ export const useCycle: typeof pinUseCycle = pinUseCycle
@@ -0,0 +1,19 @@
1
+ 'use client'
2
+ // REQ-API-037 — raw pin useScroll on web. The named option/return aliases remain the finite
3
+ // Native Motion compatibility mapping; the exported hook value itself retains the pin's exact
4
+ // signature, lifecycle, option ownership, and fault identity.
5
+
6
+ import { useScroll as pinUseScroll } from 'motion/react'
7
+ import type { RefObject } from 'react'
8
+
9
+ export type UseScrollOptions = {
10
+ readonly container?: RefObject<HTMLElement | null> | undefined
11
+ readonly target?: RefObject<HTMLElement | null> | undefined
12
+ readonly axis?: 'x' | 'y' | undefined
13
+ readonly offset?: readonly unknown[] | undefined
14
+ }
15
+
16
+ /** Pin return shape — engine MotionValues (motion/react), name-parity with the native entry. */
17
+ export type ScrollMotionValues = ReturnType<typeof pinUseScroll>
18
+
19
+ export const useScroll = pinUseScroll
@@ -0,0 +1,69 @@
1
+ import type { Transition as CoreTransition } from '@unrulysystems/native-motion-core'
2
+ import type { Transition as PinTransition } from 'motion/react'
3
+ import { MotionWebRejectionError } from './errors'
4
+
5
+ /** Motion's full web transition surface plus Native Motion's portable easing aliases. */
6
+ export type WebTransition = PinTransition | CoreTransition
7
+
8
+ function mapFlatAliases(value: unknown, owner: string): unknown {
9
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return value
10
+ const source = value as Record<string, unknown>
11
+ const aliases = (['easing', 'easings'] as const)
12
+ .filter((key) => Object.hasOwn(source, key))
13
+ .map((key) => ({ key, value: source[key] }))
14
+ .filter((entry) => entry.value !== undefined)
15
+ if (aliases.length === 0) return value
16
+ const ease = Object.hasOwn(source, 'ease') ? source['ease'] : undefined
17
+ if (aliases.length > 1 || ease !== undefined) {
18
+ const active = [...(ease === undefined ? [] : ['ease']), ...aliases.map(({ key }) => key)]
19
+ throw new MotionWebRejectionError(
20
+ `${owner}: transition may declare only one of ease, easing, or easings; received ${active.join(', ')}`,
21
+ )
22
+ }
23
+ const mapped: Record<PropertyKey, unknown> = Object.create(Object.getPrototypeOf(value))
24
+ for (const key of Reflect.ownKeys(value)) {
25
+ if (key === 'easing' || key === 'easings') continue
26
+ const descriptor = Object.getOwnPropertyDescriptor(value, key)
27
+ if (descriptor !== undefined) Object.defineProperty(mapped, key, descriptor)
28
+ }
29
+ Object.defineProperty(mapped, 'ease', {
30
+ value: aliases[0]!.value,
31
+ enumerable: true,
32
+ configurable: true,
33
+ writable: true,
34
+ })
35
+ return mapped
36
+ }
37
+
38
+ /**
39
+ * Map only Native Motion's easing aliases. Identity is preserved when no alias occurs. Property,
40
+ * default, and layout transition bags are mapped one level beneath the root, matching Motion's
41
+ * transition-with-value-overrides shape without inspecting option payloads recursively.
42
+ */
43
+ export function mapWebTransitionAliases(
44
+ transition: WebTransition,
45
+ owner = '<View>',
46
+ ): WebTransition {
47
+ const root = mapFlatAliases(transition, owner)
48
+ if (typeof root !== 'object' || root === null || Array.isArray(root)) return root as WebTransition
49
+ const source = root as Record<string, unknown>
50
+ const replacements = new Map<string, unknown>()
51
+ for (const key of Object.keys(source)) {
52
+ const original = source[key]
53
+ const nested = mapFlatAliases(original, `${owner}.${key}`)
54
+ if (nested === original) continue
55
+ replacements.set(key, nested)
56
+ }
57
+ if (replacements.size === 0) return root as WebTransition
58
+ const descriptors = Object.getOwnPropertyDescriptors(root)
59
+ for (const [key, nested] of replacements) {
60
+ const original = descriptors[key]
61
+ descriptors[key] = {
62
+ value: nested,
63
+ enumerable: original?.enumerable === true,
64
+ configurable: original?.configurable ?? true,
65
+ writable: original !== undefined && 'writable' in original ? original.writable : true,
66
+ }
67
+ }
68
+ return Object.create(Object.getPrototypeOf(root), descriptors) as WebTransition
69
+ }