@octane-xplat/motion 0.9.0 → 0.10.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/README.md CHANGED
@@ -2,9 +2,28 @@
2
2
 
3
3
  Declarative numeric motion for Octane UI on web, iOS, and Android. Use
4
4
  `motion.View`, `motion.Row`, or `motion.Pressable` with `initial`, `animate`,
5
- and `transition`. Bound motion values update hosts without rendering each frame.
5
+ and `transition`, or `drag` with numeric translation bounds. Bound motion values update hosts without rendering each frame.
6
6
 
7
- See the [motion guide](../../docs/animation-gestures.md) and maintained
7
+ ```tsx
8
+ import { motion } from '@octane-xplat/motion'
9
+ import { Text } from '@octane-xplat/ui'
10
+
11
+ export function MovingCard() {
12
+ return (
13
+ <motion.View
14
+ initial={{ opacity: 0 }}
15
+ animate={{ opacity: 1 }}
16
+ transition={{ duration: 0.3 }}
17
+ drag="x"
18
+ dragConstraints={{ left: 0, right: 120 }}
19
+ >
20
+ <Text>Drag the packing card</Text>
21
+ </motion.View>
22
+ )
23
+ }
24
+ ```
25
+
26
+ See the [motion guide](../../docs/app/animation-gestures.md) and maintained
8
27
  [MotionDemo](examples/MotionDemo.tsrx). Use the [PresenceDemo](examples/PresenceDemo.tsrx) for retained exits.
9
28
  The [compatibility record](UPSTREAM.md)
10
29
  defines the supported subset and differences from `@octanejs/motion`.
@@ -13,3 +32,13 @@ Build with `pnpm --filter @octane-xplat/motion build`; run DOM/engine tests with
13
32
  `pnpm --filter @octane-xplat/motion test` and universal lifecycle tests with
14
33
  `pnpm --filter @octane-xplat/motion exec vitest run --config vitest.native.config.mts`.
15
34
  Native compilation and object-driver tests are not physical-device evidence.
35
+
36
+ Native consumers must install `@nativescript-community/gesturehandler` and call
37
+ its `install()` before creating the root. See [drag setup](../../docs/app/animation-gestures.md#drag-a-component). Web does not require this optional peer.
38
+
39
+ ```ts
40
+ // bootstrap.mobile.ts — before Application.run or creating Page/Frame roots.
41
+ import { install } from '@nativescript-community/gesturehandler'
42
+
43
+ install()
44
+ ```
package/UPSTREAM.md CHANGED
@@ -16,6 +16,11 @@ The adapter is original code; it does not copy upstream's DOM host factory.
16
16
  | Config and reduced motion | src/context.ts; tests/conformance/reducedMotionConfig.test.ts | Context inheritance; spring transforms settle immediately on live preference changes while opacity can keep animating (`components.web.test.tsrx`, `engine.test.ts`) |
17
17
  | Exit lifecycle | src/index.ts; tests/conformance/exit.test.ts | Deliberate live-subtree retention on both leaves; presence.web.test.tsrx and native presence test |
18
18
  | Retained hosts on native | Not a DOM binding concern | components.mobile.test.tsrx uses the universal object driver |
19
+ | Bounded drag | src/index.ts pointer-drag binding; upstream/src/gestures/drag | Numeric constraints, scalar elasticity, callbacks, JS velocity spring; drag.test.ts and web/native handler tests |
20
+ | Interaction targets | whileTap/whileFocus gesture bindings | Host-level gesture seam (PointerEvents/touch/focus); snapshot + restore; components.web.test.tsrx, motion probe |
21
+ | Variant labels and bounded orchestration | src/context.ts; src/index.ts variant inheritance/stagger | variants.test.ts, web/native compiled host tests; numeric resolution before Controller |
22
+ | Scoped imperative runs | useAnimate | Controller registry keyed on host nodes; `ref` accepts callback refs and `{current}` scopes |
23
+ | Custom host components | motion.create / motion.<tag> | `motion.create(Component)` wraps shared-UI leaves; DOM tag proxy excluded |
19
24
 
20
25
  Source links: [Octane motion](https://github.com/octanejs/octane/tree/main/packages/motion),
21
26
  [upstream ledger](https://github.com/octanejs/octane/blob/main/packages/motion/UPSTREAM.md),
@@ -23,8 +28,11 @@ Source links: [Octane motion](https://github.com/octanejs/octane/tree/main/packa
23
28
 
24
29
  ## Deliberate boundaries
25
30
 
26
- - Numeric values only. No CSS strings, keyframe arrays, variants, layout,
27
- gesture presets, declarative drag, or broad framework-neutral re-export.
31
+ - Numeric values only. No CSS strings, keyframe arrays, layout/layoutId,
32
+ `whileHover`/`whileInView`, or broad framework-neutral re-export. Bounded drag,
33
+ variants, `whileTap`, `whileFocus`, lifecycle callbacks
34
+ (`onAnimationStart`/`onAnimationComplete`/`onUpdate`), `motion.create`, and
35
+ `useAnimate` match the upstream prop names.
28
36
  - Host APIs use shared UI names rather than DOM tags. Existing UI props remain
29
37
  available; motion owns transform channels and opacity. Put pre-existing CSS
30
38
  transforms on an outer container. Conflicting writers are errors.
@@ -33,14 +41,231 @@ Source links: [Octane motion](https://github.com/octanejs/octane/tree/main/packa
33
41
  - MotionConfig defaults to `reducedMotion="never"`, matching the reference;
34
42
  choose `user` for system preferences. Config does not cross separate roots and
35
43
  does not alter imperative value.animate calls: consult useReducedMotion there.
36
- - Timing uses a platform clock. NativeScript exposes frame scheduling through a
37
- module; Motion's scheduler captures a global rAF and its values use browser
38
- timing globals. The numeric adapter avoids changing application globals.
39
- - The same generator runs on web/native. WAAPI and native Animation acceleration
40
- are deferred until interruption and transform composition preserve this contract.
41
- - Springs are physical (not duration/bounce based); duration belongs to tweens.
42
- Default declarative transition is a 0.3-second easeInOut tween. Targets are
43
- absolute; scale multiplies scaleX/scaleY. Opacity is clamped to 0–1 at the host. Reduced transforms have no delay.
44
+ - Timing uses a monotonic platform clock (`System.nanoTime`/`CADisplayLink`-derived
45
+ on native, `performance.now` on web) with NativeScript's vsync scheduler.
46
+ - The same generator runs on web/native. Declarative tweens delegate to the
47
+ platform animator (`UIViewPropertyAnimator` on iOS, `ViewPropertyAnimator`
48
+ on Android, WAAPI `element.animate` on web) with per-frame presentation
49
+ tracking so interruption hands off value and velocity to the JS engine;
50
+ springs, reduced-motion runs, and gesture-driven values always run on the
51
+ JS engine. The web driver animates a matched function-list transform
52
+ keyframe plus `opacity`; sampled matrices decompose back to channels with
53
+ rotate read modulo ±180° and scale signs folded per the matrix.
54
+ - Springs accept both the physical spec (stiffness/damping/mass/velocity) and
55
+ upstream's duration/bounce spec. Transitions support `repeat`/`repeatType`
56
+ (`loop`/`reverse`/`mirror`)/`repeatDelay` and per-channel overrides in the
57
+ `{x: {…}, default: {…}}` form. Default declarative transition is a 0.3-second
58
+ easeInOut tween. Targets are absolute; scale multiplies scaleX/scaleY.
59
+ Opacity is clamped to 0–1 at the host. Reduced transforms have no delay.
60
+ Non-bezier eases, springs, repeats, and per-key transitions refuse platform
61
+ delegation and run on the JS engine on all targets.
62
+
63
+ ## Bounded declarative drag
64
+
65
+ Decision #94 widens the exclusions recorded in #92. `drag={true}` moves both
66
+ axes; `drag="x"`/`"y"` owns one axis. Numeric `dragConstraints` edges are
67
+ absolute translation limits in CSS pixels/DIP; omitted edges are unbounded.
68
+ Measured-ref constraints are rejected explicitly. `dragElastic` is a scalar
69
+ 0–1 (default 0.35; false = 0, true = 0.35), with linear resistance outside
70
+ bounds. Per-edge elasticity objects are excluded.
71
+
72
+ ```tsx
73
+ import { motion } from '@octane-xplat/motion'
74
+
75
+ export function DraggableCard() {
76
+ return <motion.View drag="x" dragConstraints={{ left: 0, right: 120 }} dragElastic={0.2} />
77
+ }
78
+ ```
79
+
80
+ `onDragStart`, `onDrag`, and `onDragEnd` receive `(event, info)`, where info has
81
+ `point`, `delta`, `offset`, `velocity` (units/second), and `cancelled`. Point is
82
+ viewport/screen coordinates. Offset and delta describe pointer movement before
83
+ constraints. Start fires on activation after an 8-unit threshold, rather than
84
+ on pointer-down; unsuccessful pre-activation gestures emit no drag callbacks.
85
+ Cancellation emits one end callback and suppresses momentum. Disposal removes
86
+ input handlers and stops settlement without emitting an end callback.
87
+
88
+ ```tsx
89
+ // motion is imported above; callbacks report gesture movement before constraints.
90
+ export function DragEvents() {
91
+ return (
92
+ <motion.View
93
+ drag
94
+ onDragStart={(_event, info) => console.log(info.point)}
95
+ onDrag={(_event, info) => console.log(info.delta, info.offset)}
96
+ onDragEnd={(_event, info) => console.log(info.velocity, info.cancelled)}
97
+ />
98
+ )
99
+ }
100
+ ```
101
+
102
+ Release projects `current + velocity * 0.2`, clamps the destination, and uses a
103
+ JS spring (stiffness 200, damping 30). `dragMomentum={false}` keeps the current
104
+ position if in bounds, or springs back from elastic overflow. This is a bounded
105
+ spring settle, **not upstream's inertia/decay algorithm**. Hard constraints
106
+ (`dragElastic={false}`) clamp every spring sample before host/value notification.
107
+ Reduced motion snaps release settlement; pointer tracking remains live.
108
+
109
+ ```tsx
110
+ export function BoundedCard() {
111
+ return (
112
+ <motion.View
113
+ drag="x"
114
+ dragConstraints={{ left: 0, right: 120 }}
115
+ dragElastic={false}
116
+ dragMomentum={false}
117
+ />
118
+ )
119
+ }
120
+ ```
121
+
122
+ A plain or spring-backed `style.x`/`style.y` MotionValue can receive drag writes;
123
+ live writes use `jump` so passive spring following cannot lag the pointer.
124
+ Drag axes reject competing `animate`, `whileTap`, or `whileFocus` targets;
125
+ `initial` seeds the position and `exit` can own it after interaction ends.
126
+ No ref measurement, dragControls, dragListener, direction lock, propagation,
127
+ snap-to-origin, dragTransition, onDragTransitionEnd, or layout projection is
128
+ implemented. `drag` axis selection is not `dragDirectionLock`.
129
+
130
+ ```tsx
131
+ import { motion, useMotionValue } from '@octane-xplat/motion'
132
+
133
+ export function LivePosition() {
134
+ const x = useMotionValue(0)
135
+ return <motion.View drag="x" style={{ x }} initial={{ x: 0 }} exit={{ x: -100 }} />
136
+ }
137
+ ```
138
+
139
+ Web uses host PointerEvents/capture and `touch-action: pan-y` for horizontal
140
+ drag, `pan-x` for vertical drag, or `none` for both. Native imports the optional
141
+ peer `@nativescript-community/gesturehandler` (pinned to 2.0.45); native consumers
142
+ must install it even when using only other motion APIs because the native
143
+ entry imports its adapter. Web consumers do not need it. Apps call `install()`
144
+ before creating their Page/Frame/root; **do not call `install(true)`**, which
145
+ would replace existing NS gesture observers. There is no raw-pan fallback.
146
+ Motion reserves handler tags from 700000000 upward within its single module
147
+ instance. Native axis activation/failure uses 8 DIP; 2.0.45's Android config
148
+ setters need explicit DIP→pixel conversion while payloads already return DIP.
149
+ On Android single-axis handlers disable the default radial slop trigger so
150
+ only the selected axis can activate. The plugin's Manager owns view-init/dispose attachment; motion removes its
151
+ state/touch listeners and detaches on cleanup.
152
+
153
+ ```ts
154
+ // Native bootstrap .mobile.ts, before creating Page/Frame/root hosts.
155
+ import { install } from '@nativescript-community/gesturehandler'
156
+
157
+ install() // Preserve existing NativeScript gesture observers.
158
+ ```
159
+
160
+ NativeViewGestureHandler is not needed for the bounded surface: motion attaches
161
+ PanGestureHandler directly to its host, with no simultaneous/waitFor graph.
162
+ Nested native control ownership and complex competing drags are deferred.
163
+ Threshold configuration is source/test evidence of arbitration policy; only
164
+ real OS input inside a ScrollView can establish runtime arbitration.
165
+
166
+ ## Variants (decision #93)
167
+
168
+ `variants` maps labels to numeric targets with an optional `transition`, or to
169
+ `(custom) => target` resolvers. The host's `custom` is passed to its own resolver;
170
+ current values and velocities are not resolver arguments. `initial`, `animate`,
171
+ `exit`, `whileTap`, and `whileFocus` accept targets, labels, or label arrays.
172
+ Arrays merge targets left to right; later channels win. Missing labels are
173
+ ignored. The last defined variant transition replaces the host/config transition
174
+ for that run; otherwise the normal default applies. Per-key transitions remain
175
+ supported. Resolvers should be pure; they may be evaluated during validation.
176
+
177
+ ```tsx
178
+ export function LabelledMotion() {
179
+ return (
180
+ <motion.View
181
+ custom={80}
182
+ initial="hidden"
183
+ animate={['shown', 'offset']}
184
+ variants={{
185
+ hidden: { opacity: 0 },
186
+ shown: { opacity: 1 },
187
+ offset: (custom: number) => ({ x: custom, transition: { duration: 0.2 } }),
188
+ }}
189
+ />
190
+ )
191
+ }
192
+ ```
193
+
194
+ Initial labels (including `initial={false}`) inherit through a root-local
195
+ context. Animate labels propagate to descendant motion hosts without their own
196
+ `animate`; each host resolves its own map and `custom`. Ordinary UI wrappers do
197
+ not break propagation. An explicit `animate` makes that host an independent
198
+ animation subtree. Direct target objects are not inherited. Both shared motion
199
+ hosts and `motion.create` provide the context.
200
+
201
+ ```tsx
202
+ import { View } from '@octane-xplat/ui'
203
+ import { motion } from '@octane-xplat/motion'
204
+
205
+ const CustomMotionView = motion.create(View)
206
+ export function InheritedMotion() {
207
+ return (
208
+ <CustomMotionView initial="hidden" animate="shown">
209
+ <motion.View variants={{ hidden: { opacity: 0 }, shown: { opacity: 1 } }} />
210
+ </CustomMotionView>
211
+ )
212
+ }
213
+ ```
214
+
215
+ On label-driven **animate** runs, the parent's resolved transition supports
216
+ numeric non-negative `delayChildren` and `staggerChildren` in seconds, applied
217
+ in child registration order and added to the child's own channel delays.
218
+ `when: 'beforeChildren'` waits for successful parent completion;
219
+ `'afterChildren'` waits for all inherited children; omitting `when` runs both
220
+ concurrently. Nested runs wait for their own descendants. Replacement and
221
+ unmount invalidate queued phases. A child mounted after a run starts joins
222
+ once that run completes, with fresh child delay; it does not extend that run's
223
+ completion barrier. Changing a retained child's resolved target/custom starts
224
+ its own inherited run. Registration order does not track keyed visual reorders.
225
+
226
+ ```tsx
227
+ export function StaggeredCards() {
228
+ return (
229
+ <motion.View
230
+ animate="shown"
231
+ variants={{
232
+ shown: {
233
+ opacity: 1,
234
+ transition: { delayChildren: 0.1, staggerChildren: 0.05, when: 'beforeChildren' },
235
+ },
236
+ }}
237
+ >
238
+ <motion.View variants={{ shown: { x: 40 } }} />
239
+ <motion.View variants={{ shown: { x: 80 } }} />
240
+ </motion.View>
241
+ )
242
+ }
243
+ ```
244
+
245
+ Exit and interaction labels resolve locally: they do not propagate activation
246
+ or child timing. Presence continues waiting for each registered host's explicit
247
+ exit; specify `exit` on children that need one. Callback completion remains
248
+ per-host playback, rather than a tree completion event. Unsupported upstream
249
+ semantics include `inherit={false}`, dynamic delay functions, `staggerDirection`,
250
+ `stagger()` helpers, gesture priority blending, `transitionEnd`, resolver chains,
251
+ and inheritance across separate renderer roots. Resolved targets and ordinary
252
+ transitions enter Controller unchanged, retaining existing delegation gates.
253
+
254
+ ```tsx
255
+ import { Presence, motion } from '@octane-xplat/motion'
256
+
257
+ export function ExitingCards({ present }: { present: boolean }) {
258
+ return (
259
+ <Presence present={present}>
260
+ <motion.View
261
+ exit="hidden"
262
+ variants={{ hidden: { opacity: 0 } }}
263
+ onAnimationComplete={() => console.log('This host completed')}
264
+ />
265
+ </Presence>
266
+ )
267
+ }
268
+ ```
44
269
 
45
270
  ## Presence divergence
46
271
 
@@ -51,12 +276,52 @@ reuse that path or claim its cleanup timing. Presence renders an explicit View
51
276
  wrapper, blocks interaction during exit, and restores interaction on reversal.
52
277
  An ancestor unmount always disposes immediately. Nested boundaries are independent.
53
278
 
279
+ ```tsx
280
+ import { useState } from 'octane'
281
+ import { Presence, motion } from '@octane-xplat/motion'
282
+ import { Button, Text } from '@octane-xplat/ui'
283
+
284
+ export function RetainedCard() {
285
+ const [present, setPresent] = useState(true)
286
+ return (
287
+ <>
288
+ <Button onPress={() => setPresent(!present)}>Toggle card</Button>
289
+ <Presence present={present}>
290
+ <motion.View exit={{ opacity: 0 }}>
291
+ <Text>Trip card</Text>
292
+ </motion.View>
293
+ </Presence>
294
+ </>
295
+ )
296
+ }
297
+ ```
298
+
54
299
  ## Verification limits
55
300
 
301
+ Web WAAPI delegation (2026-10-02): unit tests drive a fake `Animation` through
302
+ the real web adapter for keyframe shape, matrix/matrix3d decomposition with
303
+ transform-origin correction, cancel-before-sample ordering, mid-flight
304
+ value/velocity handoff, and bezier-only gating; the retained probe passes on
305
+ headless Chromium with delegation counters engaged (14 starts, 10 finishes, 4
306
+ cancellations, 0 fallbacks). DOM dispatch evidence only — compositor pacing is
307
+ not asserted.
308
+
309
+ Bounded drag (2026-10-02): the maintained probe passes 14 assertions on web
310
+ Chromium and 14 on the requested iOS simulator, including live writes, bounded
311
+ momentum, callbacks, and cancellation. Native input is plugin handler dispatch;
312
+ it does not prove OS hit-testing or drag-versus-scroll arbitration. Android drag
313
+ runtime remains unverified. Package unit/build/packed-consumer checks cover the
314
+ public types and shared numeric behavior.
315
+ The variants expansion passes 60 standard tests and 5 native object-driver
316
+ tests. The maintained probe passes 11 assertions on web Chromium and the iOS
317
+ simulator (2026-10-02), including inherited custom destinations and
318
+ parent-before-children stagger completion order; iOS platform delegation engages.
319
+ No Android runtime was available for this expansion.
320
+
56
321
  Unit and DOM tests establish bounded behavior, not full Framer Motion parity.
57
322
  Universal object-driver tests establish retention and lifecycle without an OS.
58
323
  Partial device observations are recorded in the
59
- [motion v1 validation matrix](../../docs/animation-notes.md). An isolated iOS
324
+ [motion v1 validation matrix](../../docs/notes/animation-notes.md). An isolated iOS
60
325
  26.5 MotionProbe run verified pan completion/cancellation, Presence
61
326
  focus/reversal/removal, and live Reduce Motion, including immediate transform
62
327
  settlement. The API 35 Android emulator verified tween/spring completion,
@@ -86,9 +351,12 @@ Physical Android CPH2551 evidence covers app launch and target retarget only;
86
351
  ADB sees the handset, but its keyguard is currently locked. The regular mobile app now builds and
87
352
  mounts on the iOS 26.5 simulator using NativeScript's `NSObject.extend()` API
88
353
  for the auth-session presentation delegate. The Android build also passes with
89
- the iOS delegate behind the `NSObject` runtime guard. Motion-specific simulator,
90
- emulator, and iPhone checks still use a temporary direct MotionProbe entry to
91
- isolate the driver. Physical input suppression during exit, Android gesture
354
+ the iOS delegate behind the `NSObject` runtime guard. The maintained
355
+ `examples/probes/motion.tsrx` case replaces the earlier temporary direct
356
+ MotionProbe entry (web Chromium and iOS simulator passes, 2026-10-02).
357
+ Physical input suppression during exit, Android gesture
92
358
  cancellation/natural velocity, background/resume, and frame pacing remain
93
- uncharacterized. Native preference observation polls at 500 ms while subscribed
94
- and refreshes on resume; Android reads `animator_duration_scale`.
359
+ uncharacterized. On iOS the preference is observed through
360
+ `UIAccessibilityReduceMotionStatusDidChangeNotification`; Android still polls
361
+ `animator_duration_scale` at 500 ms while subscribed (plus
362
+ `ValueAnimator.areAnimatorsEnabled()` on API 26+) and refreshes on resume.