@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 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 {}