@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/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/README.md +46 -0
- package/package.json +40 -0
- package/src/AnimatePresence.tsx +284 -0
- package/src/LayoutGroup.tsx +94 -0
- package/src/LayoutScrollOffsetProvider.tsx +30 -0
- package/src/MotionConfig.tsx +136 -0
- package/src/MotionLengthLayoutProvider.tsx +18 -0
- package/src/MotionRoot.tsx +13 -0
- package/src/View.tsx +1356 -0
- package/src/animate.ts +39 -0
- package/src/disposition.ts +167 -0
- package/src/dragControls.ts +3 -0
- package/src/errors.ts +30 -0
- package/src/index.ts +157 -0
- package/src/mode.ts +12 -0
- package/src/normalize.ts +324 -0
- package/src/snap.ts +82 -0
- package/src/useAnimate.ts +18 -0
- package/src/useCycle.ts +12 -0
- package/src/useScroll.ts +19 -0
- package/src/webTransition.ts +69 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# @unrulysystems/native-motion-web
|
|
2
|
+
|
|
3
|
+
## 0.1.0-alpha.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Initial alpha prerelease: the M3-era public surface (layout, presence, drag, gestures, variants,
|
|
8
|
+
orchestration, value types) with the cross-engine conformance suite published alongside the
|
|
9
|
+
runtime packages.
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- Updated dependencies
|
|
14
|
+
- @unrulysystems/native-motion-core@0.1.0-alpha.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Unruly Systems LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# @unrulysystems/native-motion-web
|
|
2
|
+
|
|
3
|
+
The web engine (SPEC-WEB-SHIM). A thin, validated shim over `motion/react`: wrapped `View`
|
|
4
|
+
(`motion.div`), `Text` hosts (`motion.p` by default, inline `motion.span` with `as="span"`), and
|
|
5
|
+
`Image` (`motion.img` with `src`/`alt` as DOM attributes) that add normalization and validation
|
|
6
|
+
but **never animation semantics** — on web, the engine IS motion/react (pinned `motion@12.42.2`,
|
|
7
|
+
peer). Presence (`AnimatePresence`/`usePresence`/`useIsPresent`) is a thin re-export of
|
|
8
|
+
motion/react's own primitives. The ratified universal subset includes variants and
|
|
9
|
+
`whileTap`/`whileDrag` state props; REQ-API-034's closed host set is `View`/`Text`/`Image`
|
|
10
|
+
(ratified frozen 2026-07-22, BRIEF Decisions — `motion.create` stays internal-only).
|
|
11
|
+
`layout`/`layoutId` are DECLARED since L4 and forward to real motion behind severity-law entry
|
|
12
|
+
gates.
|
|
13
|
+
|
|
14
|
+
## Module map (`src/`)
|
|
15
|
+
|
|
16
|
+
| Module | Role |
|
|
17
|
+
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
18
|
+
| `View.tsx` | The internal wrapped-host seam for public `View`, `Text`, and `Image`: routes props (disposition table → normalize; declared component props → forward; other motion-interpretable keys → loud failure; genuine DOM attrs → through), validates targets with core's `validateTarget`, owns the FLAG 5a drag family, selects `motion.p`/`motion.span` for Text and `motion.img` for Image (childless; `src` required) — the verdict is single-sourced in core (`resolveDragConfig`, identical accept/refuse to native), Motion's shapes pass through, `dragSnapPoints` maps to `dragTransition` over core-validated points |
|
|
19
|
+
| `MotionRoot.tsx` | REQ-API-022 root primitive — pass-through on web (motion/react binds recognizers per element; no gesture root needed); exists on both entries for the one-import universal surface (value-key parity) |
|
|
20
|
+
| `disposition.ts` | The prop table (REQ-WEB-012): every public prop is pass-through / normalized / extension-mapped / rejected; compile-time exhaustive (`satisfies`), runtime-total. `dragSnapPoints` is CLASSIFIED extension-mapped so a dropped/drifted mapping is a type error (REQ-WEB-013); the runtime `dragSnapPoints`→`dragTransition` mapping over core-validated points is owned by `View.tsx` — the disposition is the guard, not the mechanism |
|
|
21
|
+
| `normalize.ts` | Pure/total prop pipeline; severity is a parameter — dev throws, production reports through the error channel and refuses the property (ratified law); pins `box-sizing: border-box` |
|
|
22
|
+
| `snap.ts` | The web MAPPING only (REQ-WEB-013): `nearestSnap` + `snapPointsToDragTransition` turn core-validated points into the `dragTransition.modifyTarget` projection. Drag-config VALIDATION is single-sourced in core (`resolveDragConfig`) so both engines' accepted sets are identical |
|
|
23
|
+
| `errors.ts` | The loud-failure vocabulary (`MotionWebError` + rejection/fidelity/incompatible) |
|
|
24
|
+
| `mode.ts` | `ambientMode()` — the one place the ambient `NODE_ENV` is read |
|
|
25
|
+
| `jsdomWorld.ts` | Test infrastructure: the shared jsdom world (install/restore-scoped globals via `useJsdomWorld()`, WAAPI spy) — import-order-sensitive by design; see its header |
|
|
26
|
+
|
|
27
|
+
## Verification (Altitudes 3–6)
|
|
28
|
+
|
|
29
|
+
- Vitest verifies disposition/normalize/snap tables, jsdom end-state component tests (the REAL
|
|
30
|
+
motion/react under jsdom — end states only, never mid-flight), SSR, the gesture cross-engine
|
|
31
|
+
parity flip, the variants label-form parity cases, and the L4 layout/layoutId forwarding gates.
|
|
32
|
+
- **Altitude 5** (`e2e/`, Playwright, Chromium-only by ratification, `*.e2e.ts` so unit runners
|
|
33
|
+
never collect them): 22 tests across 13 specs — WAAPI-vs-analytic trajectory scrub, core-FLIP
|
|
34
|
+
math vs Chromium's rendered geometry (core-vs-browser, not native-vs-motion/react), real-mouse
|
|
35
|
+
drag→snap, presence removal timing, mid-flight interrupt continuity, the variants label-form
|
|
36
|
+
parity, the examples-gallery smoke with a zero-console-error net, the L4 App Store card
|
|
37
|
+
choreography specs, the layoutId crossfade parity counterpart
|
|
38
|
+
(LAYOUT_IDENTITY_PARITY_COUNTERPART), the Text layout-size proof, and the Image gallery
|
|
39
|
+
crossfade proof.
|
|
40
|
+
- `e2e/fixtures/examples.tsx` is the examples gallery — documentation-by-example for the whole
|
|
41
|
+
ratified surface; every card self-reports through `data-*` attributes so specs and humans
|
|
42
|
+
read the same settle signal.
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
cd packages/native-motion-web && bunx vitest run && bun run e2e # or: nub run e2e:web (root)
|
|
46
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@unrulysystems/native-motion-web",
|
|
3
|
+
"version": "0.1.0-alpha.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"files": [
|
|
6
|
+
"src",
|
|
7
|
+
"CHANGELOG.md",
|
|
8
|
+
"!src/**/*.test.*",
|
|
9
|
+
"!src/jsdomWorld.ts"
|
|
10
|
+
],
|
|
11
|
+
"type": "module",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": "./src/index.ts"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "vitest run",
|
|
17
|
+
"e2e:build": "bun build e2e/fixtures/app.tsx e2e/fixtures/examples.tsx --outdir e2e/dist --target browser",
|
|
18
|
+
"e2e": "bun run e2e:build && playwright test -c e2e/playwright.config.ts"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@unrulysystems/native-motion-core": "0.1.0-alpha.0",
|
|
22
|
+
"motion-dom": "12.42.2"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@playwright/test": "^1.61.1",
|
|
26
|
+
"@types/jsdom": "^27.0.0",
|
|
27
|
+
"@types/react": "^19.1.0",
|
|
28
|
+
"@types/react-dom": "^19",
|
|
29
|
+
"@unrulysystems/native-motion-conformance": "0.1.0-alpha.0",
|
|
30
|
+
"jsdom": "^26.1.0",
|
|
31
|
+
"motion": "12.42.2",
|
|
32
|
+
"react": "19.2.3",
|
|
33
|
+
"react-dom": "19.2.3",
|
|
34
|
+
"vitest": "^4.1.9"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"motion": "12.42.2",
|
|
38
|
+
"react": "^18.0.0 || ^19.0.0"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
// The shim's AnimatePresence: motion/react's component IS the presence engine (REQ-WEB-011).
|
|
3
|
+
// T24 P forwards popLayout to motion/react, whose PopChild owns the web geometry composition;
|
|
4
|
+
// T24 B1 custom is also forwarded; REQ-PRESENCE-023 preserves the pin's wider web surface.
|
|
5
|
+
import { type PresenceMode } from '@unrulysystems/native-motion-core'
|
|
6
|
+
// Namespace import: the single motion/react boundary the forwarded props contract targets
|
|
7
|
+
// (the dedicated vi.mock("motion/react") test seam proves the alias wiring).
|
|
8
|
+
import * as motionReact from 'motion/react'
|
|
9
|
+
import {
|
|
10
|
+
Children,
|
|
11
|
+
cloneElement,
|
|
12
|
+
createElement,
|
|
13
|
+
isValidElement,
|
|
14
|
+
type ComponentProps,
|
|
15
|
+
type ReactElement,
|
|
16
|
+
type ReactNode,
|
|
17
|
+
} from 'react'
|
|
18
|
+
import { MotionWebRejectionError } from './errors'
|
|
19
|
+
import { ambientMode } from './mode'
|
|
20
|
+
import type { NormalizeOptions } from './normalize'
|
|
21
|
+
import {
|
|
22
|
+
gateTransitionWithSeverity,
|
|
23
|
+
Image,
|
|
24
|
+
silentDictionaryTargetKeys,
|
|
25
|
+
Text,
|
|
26
|
+
validateWithSeverityResult,
|
|
27
|
+
} from './View'
|
|
28
|
+
|
|
29
|
+
type PinnedAnimatePresenceProps = ComponentProps<typeof motionReact.AnimatePresence>
|
|
30
|
+
|
|
31
|
+
// The universal presence contract remains portable, while REQ-PRESENCE-023 restores the pin's
|
|
32
|
+
// wider presence surface on web. Indexed access binds every web-only domain to Motion 12.42.2.
|
|
33
|
+
export interface AnimatePresenceProps {
|
|
34
|
+
mode?: PresenceMode
|
|
35
|
+
initial?: boolean
|
|
36
|
+
onExitComplete?: () => void
|
|
37
|
+
/** Pin catalog alias of onExitComplete (R13 V4); forwarded as onExitComplete to the pin. */
|
|
38
|
+
onRest?: () => void
|
|
39
|
+
// Typed `any` to match the pinned motion/react surface verbatim (the identity gate compares
|
|
40
|
+
// shapes exactly); it is gated below and never forwarded.
|
|
41
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
42
|
+
custom?: any
|
|
43
|
+
/** Family-6: nested AnimatePresence joins parent exit (forwarded to the pin). */
|
|
44
|
+
propagate?: boolean
|
|
45
|
+
root?: PinnedAnimatePresenceProps['root']
|
|
46
|
+
anchorX?: PinnedAnimatePresenceProps['anchorX']
|
|
47
|
+
anchorY?: PinnedAnimatePresenceProps['anchorY']
|
|
48
|
+
presenceAffectsLayout?: PinnedAnimatePresenceProps['presenceAffectsLayout']
|
|
49
|
+
children?: ReactNode
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Pin-catalog onRest → pin library onExitComplete (REQ-PRESENCE-010 R13 V4).
|
|
54
|
+
* Pure: deleting the alias (or flipping precedence) must red the web suite.
|
|
55
|
+
* `onExitComplete` wins when both are supplied — never double-fire.
|
|
56
|
+
*/
|
|
57
|
+
export function resolvePresenceExitComplete(
|
|
58
|
+
onExitComplete?: () => void,
|
|
59
|
+
onRest?: () => void,
|
|
60
|
+
): (() => void) | undefined {
|
|
61
|
+
return onExitComplete ?? onRest
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The native container's child-target boundary, mirrored IN ORDER (review round 12 major 38 —
|
|
65
|
+
// REQ-PRESENCE-019 universality): gate `animate`, gate `exit` — both through the same
|
|
66
|
+
// validation lane the View uses, so a malformed shape or an unsupported key fails in the SAME
|
|
67
|
+
// category as native (target validation, never a premature subset complaint) — and only THEN
|
|
68
|
+
// apply the exit-subset rule against the GATED animate keys (round 11: an exit key outside the
|
|
69
|
+
// child's animate keys has no shared value on the native engine's fixed-at-mount scope).
|
|
70
|
+
// Development throws at the first offense; production reports each offense and refuses it —
|
|
71
|
+
// the child is re-rendered on the GATED targets (native buildPresenceChildProps parity), so a
|
|
72
|
+
// refused key never applies and the child's own boundary sees only clean values (one report
|
|
73
|
+
// per offender, never two). A fully-refused exit is OMITTED: the no-exit removal path.
|
|
74
|
+
function componentIdForPresenceChild(type: unknown): '<Text>' | '<View>' | '<Image>' {
|
|
75
|
+
const visited = new Set<object>()
|
|
76
|
+
let current = type
|
|
77
|
+
for (let depth = 0; depth < 16; depth += 1) {
|
|
78
|
+
if (current === Text) return '<Text>'
|
|
79
|
+
if (current === Image) return '<Image>'
|
|
80
|
+
if ((typeof current !== 'object' && typeof current !== 'function') || current === null) break
|
|
81
|
+
if (visited.has(current)) break
|
|
82
|
+
visited.add(current)
|
|
83
|
+
// React.memo stores its wrapped type on `.type`; forwardRef stays opaque because invoking
|
|
84
|
+
// it to predict its host would violate React's render ownership.
|
|
85
|
+
const wrapped = (current as { type?: unknown }).type
|
|
86
|
+
if (wrapped === undefined) break
|
|
87
|
+
current = wrapped
|
|
88
|
+
}
|
|
89
|
+
return '<View>'
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function gateChildTargets(child: ReactElement, options: NormalizeOptions): ReactElement {
|
|
93
|
+
const componentId = componentIdForPresenceChild(child.type)
|
|
94
|
+
const childProps = child.props as {
|
|
95
|
+
readonly animate?: unknown
|
|
96
|
+
readonly exit?: unknown
|
|
97
|
+
readonly variants?: unknown
|
|
98
|
+
readonly transition?: unknown
|
|
99
|
+
readonly layout?: unknown
|
|
100
|
+
readonly whileTap?: unknown
|
|
101
|
+
readonly whileDrag?: unknown
|
|
102
|
+
}
|
|
103
|
+
if (childProps.animate === undefined && childProps.exit === undefined) return child
|
|
104
|
+
// R7 (REQ-API-032): LABEL-form child props pass through this gate untouched — the child
|
|
105
|
+
// View owns their string-only validity and motion owns their resolution; target
|
|
106
|
+
// validation on a string would be a raw shape crash.
|
|
107
|
+
const isLabelForm = (value: unknown): boolean => typeof value === 'string' || Array.isArray(value)
|
|
108
|
+
const animateIsLabel = isLabelForm(childProps.animate)
|
|
109
|
+
const exitIsLabel = isLabelForm(childProps.exit)
|
|
110
|
+
// Motion's runtime (though not its public MotionProps types at this pin) accepts a direct
|
|
111
|
+
// TargetResolver. It must reach the visual element unchanged; its result does not exist yet for
|
|
112
|
+
// this native-subset target gate to inspect (REQ-API-056).
|
|
113
|
+
const animateIsResolver = typeof childProps.animate === 'function'
|
|
114
|
+
const exitIsResolver = typeof childProps.exit === 'function'
|
|
115
|
+
// Capture and gate once here: the same frozen transition governs both presence consumers and is
|
|
116
|
+
// forwarded to the child, so an exit subset cannot validate a different timing truth from View.
|
|
117
|
+
const transition =
|
|
118
|
+
childProps.transition === undefined
|
|
119
|
+
? undefined
|
|
120
|
+
: gateTransitionWithSeverity(childProps.transition as never, options, componentId)
|
|
121
|
+
const animateResult =
|
|
122
|
+
childProps.animate === undefined || animateIsLabel || animateIsResolver
|
|
123
|
+
? undefined
|
|
124
|
+
: validateWithSeverityResult(
|
|
125
|
+
childProps.animate as never,
|
|
126
|
+
'animate',
|
|
127
|
+
options,
|
|
128
|
+
undefined,
|
|
129
|
+
false,
|
|
130
|
+
componentId,
|
|
131
|
+
)
|
|
132
|
+
const animate = animateResult?.target
|
|
133
|
+
const exitResult =
|
|
134
|
+
childProps.exit === undefined || exitIsLabel || exitIsResolver
|
|
135
|
+
? undefined
|
|
136
|
+
: validateWithSeverityResult(
|
|
137
|
+
childProps.exit as never,
|
|
138
|
+
'exit',
|
|
139
|
+
options,
|
|
140
|
+
undefined,
|
|
141
|
+
false,
|
|
142
|
+
componentId,
|
|
143
|
+
)
|
|
144
|
+
let exit = exitResult?.target
|
|
145
|
+
// R7 law (g): the exit-subset rule admits the MOUNTED union — the gated object-form
|
|
146
|
+
// animate keys ∪ the dictionary's ACCEPTED target keys (M3 r1 majors 7a95f00c503d +
|
|
147
|
+
// 0a30cfcc17c2: a dictionary-named key IS mounted on the native engine, and a label-form
|
|
148
|
+
// animate resolves INSIDE that dictionary, so the union needs no resolution and the rule
|
|
149
|
+
// never skips — an unmounted exit key refuses identically on both engines). A label-form
|
|
150
|
+
// EXIT forwards ungated: its resolution is dictionary-bounded by construction.
|
|
151
|
+
if (exit !== undefined && !exitIsResolver && !animateIsResolver) {
|
|
152
|
+
const mountedKeys = new Set(Object.keys(animate ?? {}))
|
|
153
|
+
for (const key of silentDictionaryTargetKeys(childProps.variants, options, componentId)) {
|
|
154
|
+
mountedKeys.add(key)
|
|
155
|
+
}
|
|
156
|
+
const kept: Record<string, unknown> = {}
|
|
157
|
+
for (const [key, value] of Object.entries(exit)) {
|
|
158
|
+
if (!mountedKeys.has(key)) {
|
|
159
|
+
const error = new MotionWebRejectionError(
|
|
160
|
+
`${componentId}: presence exit key '${key}' is not in the child's mounted key set (the animate ` +
|
|
161
|
+
'target ∪ the variants dictionary, REQ-API-032 law g) — an un-mounted key has ' +
|
|
162
|
+
'no shared value.',
|
|
163
|
+
)
|
|
164
|
+
if (options.mode === 'development') throw error
|
|
165
|
+
options.report(error)
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
// `value` is drawn from `exit`, which IS the validateWithSeverity snapshot above — each keyframe
|
|
169
|
+
// array member was already materialized once (R8, review major 23), so this exit-subset forward
|
|
170
|
+
// carries the frozen snapshot to the child View, never a caller accessor read a second time.
|
|
171
|
+
kept[key] = value
|
|
172
|
+
}
|
|
173
|
+
exit = kept as never
|
|
174
|
+
}
|
|
175
|
+
return cloneElement(child as ReactElement<Record<string, unknown>>, {
|
|
176
|
+
...(animateIsResolver || animate === undefined ? {} : { animate }),
|
|
177
|
+
...(childProps.transition === undefined ? {} : { transition }),
|
|
178
|
+
// A LABEL exit stays the child's own prop (the View refuses it named); a fully-refused
|
|
179
|
+
// OBJECT exit is stripped so the child follows the no-exit removal path.
|
|
180
|
+
...(exitIsLabel || exitIsResolver
|
|
181
|
+
? {}
|
|
182
|
+
: exit === undefined || Object.keys(exit).length === 0
|
|
183
|
+
? { exit: undefined }
|
|
184
|
+
: { exit }),
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Factory so tests (and advanced hosts) pin the severity mode and error channel explicitly —
|
|
189
|
+
// the createView pattern; the exported AnimatePresence binds the ambient mode below.
|
|
190
|
+
export function createAnimatePresence(options: NormalizeOptions) {
|
|
191
|
+
return function AnimatePresence(props: AnimatePresenceProps) {
|
|
192
|
+
const {
|
|
193
|
+
mode,
|
|
194
|
+
custom,
|
|
195
|
+
initial,
|
|
196
|
+
onExitComplete,
|
|
197
|
+
onRest,
|
|
198
|
+
propagate,
|
|
199
|
+
root,
|
|
200
|
+
anchorX,
|
|
201
|
+
anchorY,
|
|
202
|
+
presenceAffectsLayout,
|
|
203
|
+
children: rawChildren,
|
|
204
|
+
...rest
|
|
205
|
+
} = props
|
|
206
|
+
// Unknown props still refuse under the severity law. Pin-owned web presence props are
|
|
207
|
+
// destructured above and forwarded below; native incompleteness never routes them here.
|
|
208
|
+
for (const [key, value] of Object.entries(rest as Record<string, unknown>)) {
|
|
209
|
+
if (value === undefined) continue
|
|
210
|
+
const error = new MotionWebRejectionError(
|
|
211
|
+
`AnimatePresence prop '${key}' is outside the pinned and contracted presence surfaces — ` +
|
|
212
|
+
'an unmapped Native Motion extension fails loud instead of silently no-oping ' +
|
|
213
|
+
'(REQ-WEB-015/REQ-PRESENCE-019).',
|
|
214
|
+
)
|
|
215
|
+
if (options.mode === 'development') throw error
|
|
216
|
+
options.report(error) // refused: never forwarded
|
|
217
|
+
}
|
|
218
|
+
const forwarded: Record<string, unknown> = {}
|
|
219
|
+
if (initial !== undefined) forwarded['initial'] = initial
|
|
220
|
+
if (root !== undefined) forwarded['root'] = root
|
|
221
|
+
if (anchorX !== undefined) forwarded['anchorX'] = anchorX
|
|
222
|
+
if (anchorY !== undefined) forwarded['anchorY'] = anchorY
|
|
223
|
+
if (presenceAffectsLayout !== undefined) {
|
|
224
|
+
forwarded['presenceAffectsLayout'] = presenceAffectsLayout
|
|
225
|
+
}
|
|
226
|
+
// R13 V4: pin-catalog onRest aliases onExitComplete for the pin library forward.
|
|
227
|
+
const exitComplete = resolvePresenceExitComplete(onExitComplete, onRest)
|
|
228
|
+
if (exitComplete !== undefined) forwarded['onExitComplete'] = exitComplete
|
|
229
|
+
|
|
230
|
+
// Validation order matches native render-for-render (major 58fa79fa334a / REQ-PRESENCE-019):
|
|
231
|
+
// mode → custom → propagate. Combined invalid props report the same first offense on both.
|
|
232
|
+
if (mode !== undefined) {
|
|
233
|
+
if (mode !== 'sync' && mode !== 'wait' && mode !== 'popLayout') {
|
|
234
|
+
// The mode VALUE domain is closed (round 17 major d11c0c6e9a42): an unsupported value
|
|
235
|
+
// must fail loud on both engines, never be forwarded for motion to interpret.
|
|
236
|
+
const error = new MotionWebRejectionError(
|
|
237
|
+
`presence mode '${String(mode)}' is not a valid mode — REQ-PRESENCE-010 admits ` +
|
|
238
|
+
"'sync' | 'wait' | 'popLayout' (development throws; production reports and falls " +
|
|
239
|
+
'back to the default).',
|
|
240
|
+
)
|
|
241
|
+
if (options.mode === 'development') throw error
|
|
242
|
+
options.report(error) // refused: motion runs with its default mode
|
|
243
|
+
} else {
|
|
244
|
+
forwarded['mode'] = mode
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// T24 B1: motion/react owns presence custom precedence and activation-time binding on web.
|
|
248
|
+
if (custom !== undefined) forwarded['custom'] = custom
|
|
249
|
+
// Family-6: closed boolean domain (major 29845259d444) + forward pin true/false only.
|
|
250
|
+
if (propagate !== undefined) {
|
|
251
|
+
if (propagate !== true && propagate !== false) {
|
|
252
|
+
const error = new MotionWebRejectionError(
|
|
253
|
+
`AnimatePresence prop 'propagate' must be boolean (got ${typeof propagate}); ` +
|
|
254
|
+
'invalid values are refused under the severity law (REQ-PRESENCE-010/017).',
|
|
255
|
+
)
|
|
256
|
+
if (options.mode === 'development') throw error
|
|
257
|
+
options.report(error)
|
|
258
|
+
} else {
|
|
259
|
+
forwarded['propagate'] = propagate
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Children.map keeps keys stable across renders (prefixing is deterministic), so motion's
|
|
264
|
+
// key-tracked presence lifecycle is unaffected — the Alt-5 presence e2e pins this live.
|
|
265
|
+
// Child ACCEPTANCE is motion's own (round 19 major 6ca42f0e9bd3: round-18's loud gate was
|
|
266
|
+
// an unratified divergence from the pinned contract — motion filters non-elements
|
|
267
|
+
// silently and accepts keyless elements; the native container now mirrors that). Only
|
|
268
|
+
// ELEMENT children ride the target gate; everything else forwards for motion to filter.
|
|
269
|
+
const children = Children.map(rawChildren, (child) =>
|
|
270
|
+
isValidElement(child) ? gateChildTargets(child, options) : child,
|
|
271
|
+
)
|
|
272
|
+
return createElement(motionReact.AnimatePresence, forwarded, children)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/* The default error channel: loud in the console, never swallowed. */
|
|
277
|
+
function consoleReporter(error: Error): void {
|
|
278
|
+
console.error(error)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export const AnimatePresence = createAnimatePresence({
|
|
282
|
+
mode: ambientMode(),
|
|
283
|
+
report: consoleReporter,
|
|
284
|
+
})
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
// REQ-API-036 — LayoutGroup web entry: thin validated re-export of the pin's own
|
|
3
|
+
// LayoutGroup from motion/react (REQ-WEB-011). No custom web projection engine —
|
|
4
|
+
// validation + severity law only; the pin owns id namespacing and projection groups.
|
|
5
|
+
|
|
6
|
+
import { describeValue } from '@unrulysystems/native-motion-core'
|
|
7
|
+
import { LayoutGroup as PinLayoutGroup } from 'motion/react'
|
|
8
|
+
import { createElement, type ComponentProps, type ReactNode } from 'react'
|
|
9
|
+
import { MotionWebRejectionError } from './errors'
|
|
10
|
+
import { ambientMode } from './mode'
|
|
11
|
+
import type { NormalizeOptions } from './normalize'
|
|
12
|
+
|
|
13
|
+
export type LayoutGroupInherit = boolean | 'id'
|
|
14
|
+
|
|
15
|
+
export interface LayoutGroupProps {
|
|
16
|
+
readonly children?: ReactNode
|
|
17
|
+
readonly id?: string | undefined
|
|
18
|
+
readonly inherit?: LayoutGroupInherit | undefined
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const LAYOUT_GROUP_OWNER = '<LayoutGroup>' as const
|
|
22
|
+
|
|
23
|
+
const ACCEPTED_PROP_NAMES = new Set(['children', 'id', 'inherit'])
|
|
24
|
+
|
|
25
|
+
function refuse(options: NormalizeOptions, message: string): void {
|
|
26
|
+
const error = new MotionWebRejectionError(message)
|
|
27
|
+
if (options.mode === 'development') throw error
|
|
28
|
+
options.report(error)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function gateId(value: unknown, options: NormalizeOptions): string | undefined {
|
|
32
|
+
if (value === undefined) return undefined
|
|
33
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
34
|
+
refuse(
|
|
35
|
+
options,
|
|
36
|
+
`${LAYOUT_GROUP_OWNER}: invalid id = ${describeValue(value)} — expected a non-empty string ` +
|
|
37
|
+
'(REQ-API-036).',
|
|
38
|
+
)
|
|
39
|
+
return undefined
|
|
40
|
+
}
|
|
41
|
+
return value
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function gateInherit(value: unknown, options: NormalizeOptions): LayoutGroupInherit | undefined {
|
|
45
|
+
if (value === undefined) return undefined
|
|
46
|
+
if (value === true || value === false || value === 'id') return value
|
|
47
|
+
refuse(
|
|
48
|
+
options,
|
|
49
|
+
`${LAYOUT_GROUP_OWNER}: invalid inherit = ${describeValue(value)} — expected ` +
|
|
50
|
+
`true | false | "id" (REQ-API-036).`,
|
|
51
|
+
)
|
|
52
|
+
return undefined
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function gateUnknownProps(
|
|
56
|
+
rest: Readonly<Record<string, unknown>>,
|
|
57
|
+
options: NormalizeOptions,
|
|
58
|
+
): void {
|
|
59
|
+
for (const key of Object.keys(rest)) {
|
|
60
|
+
if (rest[key] === undefined) continue
|
|
61
|
+
if (ACCEPTED_PROP_NAMES.has(key)) continue
|
|
62
|
+
refuse(
|
|
63
|
+
options,
|
|
64
|
+
`${LAYOUT_GROUP_OWNER}: prop '${key}' is outside the contracted LayoutGroup surface ` +
|
|
65
|
+
'(REQ-API-036: id/inherit/children) — it fails loud rather than silently doing nothing.',
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function createLayoutGroup(options: NormalizeOptions) {
|
|
71
|
+
return function LayoutGroup({ children, id, inherit, ...rest }: LayoutGroupProps) {
|
|
72
|
+
gateUnknownProps(rest as Record<string, unknown>, options)
|
|
73
|
+
const gatedId = gateId(id, options)
|
|
74
|
+
const gatedInherit = gateInherit(inherit, options)
|
|
75
|
+
|
|
76
|
+
// exactOptionalPropertyTypes: only present keys are set; undefined must not be written.
|
|
77
|
+
type PinProps = ComponentProps<typeof PinLayoutGroup>
|
|
78
|
+
const forwarded = {
|
|
79
|
+
...(gatedId !== undefined ? { id: gatedId } : {}),
|
|
80
|
+
...(gatedInherit !== undefined ? { inherit: gatedInherit } : {}),
|
|
81
|
+
} satisfies PinProps
|
|
82
|
+
|
|
83
|
+
return createElement(PinLayoutGroup, forwarded, children)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function consoleReporter(error: Error): void {
|
|
88
|
+
console.error(error)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const LayoutGroup = createLayoutGroup({
|
|
92
|
+
mode: ambientMode(),
|
|
93
|
+
report: consoleReporter,
|
|
94
|
+
})
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
// Web counterpart of native LayoutScrollOffsetProvider (REQ-LAYOUT-022(c) namespace parity).
|
|
3
|
+
// Pin motion/react owns container-scroll measure on overflow:scroll layoutScroll hosts — this
|
|
4
|
+
// composition is a no-op passthrough so dual-entry product code can share the same tree.
|
|
5
|
+
|
|
6
|
+
import type { ReactNode } from 'react'
|
|
7
|
+
|
|
8
|
+
export type LayoutScrollOffset = { readonly x: number; readonly y: number }
|
|
9
|
+
export type LayoutScrollOffsetRef = { current: LayoutScrollOffset }
|
|
10
|
+
|
|
11
|
+
export interface LayoutScrollOffsetProviderProps {
|
|
12
|
+
readonly getOffset: () => LayoutScrollOffset
|
|
13
|
+
readonly children: ReactNode
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function LayoutScrollOffsetProvider({
|
|
17
|
+
children,
|
|
18
|
+
}: LayoutScrollOffsetProviderProps): ReactNode {
|
|
19
|
+
return children
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createLayoutScrollOffsetRef(): LayoutScrollOffsetRef {
|
|
23
|
+
return { current: { x: 0, y: 0 } }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Web: no-op (pin reads scrollLeft/scrollTop on the layoutScroll host itself). */
|
|
27
|
+
export function recordLayoutScrollOffsetFromEvent(
|
|
28
|
+
_event: unknown,
|
|
29
|
+
_target: LayoutScrollOffsetRef,
|
|
30
|
+
): void {}
|