@unrulysystems/native-motion 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 +16 -0
- package/LICENSE +21 -0
- package/README.md +55 -0
- package/android/build.gradle +24 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/com/unrulysystems/nativemotion/GestureExclusionModule.kt +55 -0
- package/android/src/main/java/com/unrulysystems/nativemotion/NativeMotionPackage.kt +27 -0
- package/package.json +57 -0
- package/react-native.config.js +14 -0
- package/src/native/driver/instantWindow.ts +31 -0
- package/src/native/driver/strictModeReplay.ts +20 -0
- package/src/native/driver/uiLayoutEngine.ts +845 -0
- package/src/native/driver/uiLayoutGraph.ts +338 -0
- package/src/native/driver/uiValueChannel.ts +4640 -0
- package/src/native/driver/workletDriver.ts +3860 -0
- package/src/native/motion/AnimatePresence.tsx +1605 -0
- package/src/native/motion/LayoutGroup.tsx +163 -0
- package/src/native/motion/MotionConfig.tsx +400 -0
- package/src/native/motion/MotionRoot.tsx +250 -0
- package/src/native/motion/MotionView.tsx +6761 -0
- package/src/native/motion/addScaleCorrector.ts +97 -0
- package/src/native/motion/colorEndpointUiFeed.ts +32 -0
- package/src/native/motion/colorProjection.ts +39 -0
- package/src/native/motion/composeTransform.ts +271 -0
- package/src/native/motion/constraintMeasure.ts +157 -0
- package/src/native/motion/deferredPendingSnapshots.ts +25 -0
- package/src/native/motion/discreteProjection.ts +36 -0
- package/src/native/motion/dragAncestorPanContext.ts +28 -0
- package/src/native/motion/dragControls.ts +152 -0
- package/src/native/motion/dragGestureWiring.ts +1168 -0
- package/src/native/motion/dragHandoffBinding.ts +179 -0
- package/src/native/motion/dragHubStream.ts +756 -0
- package/src/native/motion/dragPropagationLock.ts +57 -0
- package/src/native/motion/driverValueChannel.ts +3288 -0
- package/src/native/motion/externalDragDriver.ts +214 -0
- package/src/native/motion/frameData.ts +31 -0
- package/src/native/motion/gestureBinding.ts +107 -0
- package/src/native/motion/gestureStateGate.ts +563 -0
- package/src/native/motion/gestureStateResolver.ts +286 -0
- package/src/native/motion/identityValueChannelControllerAdapter.ts +2043 -0
- package/src/native/motion/identityValueChannelControllerReconciliation.ts +176 -0
- package/src/native/motion/identityValueChannelLaneMarker.ts +14 -0
- package/src/native/motion/imperativeAnimate.ts +816 -0
- package/src/native/motion/keyframeTiming.ts +7 -0
- package/src/native/motion/layoutIdBinding.ts +261 -0
- package/src/native/motion/layoutIdFlightConfig.ts +51 -0
- package/src/native/motion/layoutProjection.ts +89 -0
- package/src/native/motion/layoutScroll.ts +204 -0
- package/src/native/motion/layoutTransition.ts +1302 -0
- package/src/native/motion/lengthLayoutContext.tsx +100 -0
- package/src/native/motion/lengthLayoutHost.ts +44 -0
- package/src/native/motion/mappedKeys.ts +184 -0
- package/src/native/motion/motionViewController.ts +3180 -0
- package/src/native/motion/nativeHostMarker.ts +22 -0
- package/src/native/motion/panSessionWiring.ts +127 -0
- package/src/native/motion/pathTransition.ts +101 -0
- package/src/native/motion/popLayout.ts +176 -0
- package/src/native/motion/presenceBinding.ts +382 -0
- package/src/native/motion/scaleCorrectorRegistry.ts +123 -0
- package/src/native/motion/scrollValues.ts +260 -0
- package/src/native/motion/serializablePayload.ts +151 -0
- package/src/native/motion/severity.ts +16 -0
- package/src/native/motion/shippedSurface.ts +843 -0
- package/src/native/motion/staticLengthGate.ts +86 -0
- package/src/native/motion/styleBaseGate.ts +72 -0
- package/src/native/motion/styleValueBinding.ts +657 -0
- package/src/native/motion/systemGestureExclusion.ts +61 -0
- package/src/native/motion/tapGestureWiring.ts +215 -0
- package/src/native/motion/transformOrder.ts +38 -0
- package/src/native/motion/transformStringBinding.ts +200 -0
- package/src/native/motion/transformTemplateGate.ts +45 -0
- package/src/native/motion/transitionGate.ts +435 -0
- package/src/native/motion/useAnimate.ts +52 -0
- package/src/native/motion/useCycle.ts +44 -0
- package/src/native/motion/useInstantTransition.ts +68 -0
- package/src/native/motion/useReducedMotion.ts +58 -0
- package/src/native/motion/useScroll.ts +162 -0
- package/src/native/motion/useViewportScroll.ts +29 -0
- package/src/native/motion/valueChannel.ts +7121 -0
- package/src/native/motion/valueHooks.ts +901 -0
- package/src/native/motion/variantChildRegistry.ts +67 -0
- package/src/native/motion/variantContext.tsx +172 -0
- package/src/native/motion/variantProps.ts +598 -0
- package/src/native.ts +159 -0
- package/src/verification/harnessMetrics.ts +78 -0
- package/src/verification/probe/LayoutIdentityWorkletProbe.tsx +251 -0
- package/src/verification/probe/WorkletParityProbe.tsx +162 -0
- package/src/verification/probe/layoutIdentityProbeEngine.ts +429 -0
- package/src/verification/screens/ArcPathChecksScreen.tsx +273 -0
- package/src/verification/screens/BooleanAnimateChecksScreen.tsx +358 -0
- package/src/verification/screens/ChoreographyGalleryScreen.tsx +1010 -0
- package/src/verification/screens/ColorBindingChecksScreen.tsx +568 -0
- package/src/verification/screens/CompletionChecksScreen.tsx +294 -0
- package/src/verification/screens/ConformanceScreen.tsx +573 -0
- package/src/verification/screens/ContentionProbeScreen.tsx +224 -0
- package/src/verification/screens/DriverSmokeScreen.tsx +99 -0
- package/src/verification/screens/DurationOnlyTweenChecksScreen.tsx +294 -0
- package/src/verification/screens/DynamicDragConfigChecksScreen.tsx +237 -0
- package/src/verification/screens/FrameDataChecksScreen.tsx +252 -0
- package/src/verification/screens/GestureChecksScreen.tsx +685 -0
- package/src/verification/screens/InstantTransitionChecksScreen.tsx +839 -0
- package/src/verification/screens/LayoutAnimationStartChecksScreen.tsx +648 -0
- package/src/verification/screens/LayoutChecksScreen.tsx +822 -0
- package/src/verification/screens/LayoutCommitSpikeScreen.tsx +142 -0
- package/src/verification/screens/MotionViewChecksScreen.tsx +930 -0
- package/src/verification/screens/PresenceChecksScreen.tsx +614 -0
- package/src/verification/screens/ReducedMotionChecksScreen.tsx +614 -0
- package/src/verification/screens/RestThresholdChecksScreen.tsx +634 -0
- package/src/verification/screens/ScaleCorrectorChecksScreen.tsx +425 -0
- package/src/verification/screens/SharedLayoutContinuityChecksScreen.tsx +338 -0
- package/src/verification/screens/SharedLayoutCrossfadeChecksScreen.tsx +425 -0
- package/src/verification/screens/TransitionDefaultSelectionChecksScreen.tsx +514 -0
- package/src/verification/screens/ViewportScrollAliasChecksScreen.tsx +303 -0
- package/src/verification/screens/conformanceBanner.ts +21 -0
- package/src/verification/screens/proofConsoleTap.ts +18 -0
- package/src/verification.ts +47 -0
- package/src/web.ts +98 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// U7g (REQ-LAYOUT-024): native addScaleCorrector. Pin motion-dom scale-correction.ts merges
|
|
2
|
+
// into a JS map; native projection runs on the UI thread so every `correct` must be a branded
|
|
3
|
+
// worklet (F1). CSS-variable keys and unmapped keys refuse loud (no native meaning). boxShadow
|
|
4
|
+
// has no native mapping (F3) — never a silent built-in.
|
|
5
|
+
|
|
6
|
+
import { scheduleOnUI } from 'react-native-worklets'
|
|
7
|
+
import { NUMERIC_STYLE_KEY_NAMES } from './mappedKeys'
|
|
8
|
+
import { installScaleCorrectorOnUI } from './scaleCorrectorRegistry'
|
|
9
|
+
import { ambientNativeSeverity, consoleReporter } from './severity'
|
|
10
|
+
|
|
11
|
+
export interface ScaleCorrectionContext {
|
|
12
|
+
readonly targetDelta: {
|
|
13
|
+
readonly x: { readonly scale: number }
|
|
14
|
+
readonly y: { readonly scale: number }
|
|
15
|
+
}
|
|
16
|
+
readonly treeScale: { readonly x: number; readonly y: number }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type ScaleCorrector = (
|
|
20
|
+
latest: number | string,
|
|
21
|
+
context: ScaleCorrectionContext,
|
|
22
|
+
) => number | string
|
|
23
|
+
|
|
24
|
+
export interface ScaleCorrectorDefinition {
|
|
25
|
+
readonly correct: ScaleCorrector
|
|
26
|
+
readonly applyTo?: readonly string[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ScaleCorrectorMap {
|
|
30
|
+
readonly [key: string]: ScaleCorrectorDefinition
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type BrandedWorklet = ScaleCorrector & { readonly __workletHash?: unknown }
|
|
34
|
+
|
|
35
|
+
const BINDABLE = new Set<string>(NUMERIC_STYLE_KEY_NAMES)
|
|
36
|
+
|
|
37
|
+
function isCssVariableName(key: string): boolean {
|
|
38
|
+
return key.startsWith('--')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function refusalForEntry(key: string, definition: ScaleCorrectorMap[string]): Error | null {
|
|
42
|
+
if (isCssVariableName(key)) {
|
|
43
|
+
return new Error(
|
|
44
|
+
`addScaleCorrector: "${key}" is a CSS variable — native has no CSS-variable style ` +
|
|
45
|
+
'surface (REQ-LAYOUT-024). Register a bindable native style key.',
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
if (!BINDABLE.has(key)) {
|
|
49
|
+
return new Error(
|
|
50
|
+
`addScaleCorrector: "${key}" is not a bindable native style key (REQ-LAYOUT-024). ` +
|
|
51
|
+
'Native scale correction applies to mapped numeric layout-style keys only.',
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
if (typeof definition.correct !== 'function') {
|
|
55
|
+
return new Error(
|
|
56
|
+
`addScaleCorrector: "${key}".correct must be a function (latest, context) => value.`,
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
if ((definition.correct as BrandedWorklet).__workletHash === undefined) {
|
|
60
|
+
return new Error(
|
|
61
|
+
`addScaleCorrector: "${key}".correct must carry the 'worklet' directive — native ` +
|
|
62
|
+
'projection corrects on the UI runtime every frame (REQ-LAYOUT-024 F1). Add ' +
|
|
63
|
+
"'worklet' as the function body's first statement.",
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
const applyTo = definition.applyTo
|
|
67
|
+
if (applyTo !== undefined) {
|
|
68
|
+
for (const target of applyTo) {
|
|
69
|
+
if (isCssVariableName(target) || !BINDABLE.has(target)) {
|
|
70
|
+
return new Error(
|
|
71
|
+
`addScaleCorrector: "${key}".applyTo contains "${target}", which is not a ` +
|
|
72
|
+
'bindable native style key (REQ-LAYOUT-024).',
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Register per-frame projection scale correctors. Each `correct` runs on the UI runtime
|
|
82
|
+
* during an active projection transform (the pin's addScaleCorrector channel).
|
|
83
|
+
*/
|
|
84
|
+
export function addScaleCorrector(correctors: ScaleCorrectorMap): void {
|
|
85
|
+
const severity = ambientNativeSeverity()
|
|
86
|
+
for (const key of Object.keys(correctors)) {
|
|
87
|
+
const definition = correctors[key]!
|
|
88
|
+
const error = refusalForEntry(key, definition)
|
|
89
|
+
if (error !== null) {
|
|
90
|
+
if (severity === 'development') throw error
|
|
91
|
+
consoleReporter(error)
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
const applyTo = definition.applyTo === undefined ? null : [...definition.applyTo]
|
|
95
|
+
scheduleOnUI(installScaleCorrectorOnUI, key, definition.correct, applyTo)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Endpoint SharedValue feeds must not mutate from the JS guest (iOS serialization hang,
|
|
2
|
+
// PROOF §SIGABRT-1 Q3 / host-feed law): a JS-thread `cell.value = …` on a Reanimated SharedValue
|
|
3
|
+
// schedules flushUIQueue → workletFunction → mutable, the hang tail
|
|
4
|
+
// /__closure/workletFunction/__closure/mutable/__closure. Mount-time makeMutable is fine (no
|
|
5
|
+
// guest write); retargets ride scheduleOnUI + this named worklet (primitive/branded args only).
|
|
6
|
+
|
|
7
|
+
import type { RGBA } from '@unrulysystems/native-motion-core'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* UI-lane writer for a color keyframe endpoint sequence SharedValue.
|
|
11
|
+
* Call only via scheduleOnUI(setColorEndpointSequenceOnUI, cell, sequence).
|
|
12
|
+
*/
|
|
13
|
+
export function setColorEndpointSequenceOnUI(
|
|
14
|
+
sequenceCell: { value: readonly RGBA[] },
|
|
15
|
+
sequence: readonly RGBA[],
|
|
16
|
+
): void {
|
|
17
|
+
'worklet'
|
|
18
|
+
sequenceCell.value = sequence
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* UI-lane writer for a discrete [from, to] keyword pair SharedValue (T23 B2b — same guest-write
|
|
23
|
+
* law as the color feed; strings are primitive-safe for the crossing).
|
|
24
|
+
* Call only via scheduleOnUI(setDiscreteEndpointPairOnUI, cell, pair).
|
|
25
|
+
*/
|
|
26
|
+
export function setDiscreteEndpointPairOnUI(
|
|
27
|
+
pairCell: { value: readonly string[] },
|
|
28
|
+
pair: readonly string[],
|
|
29
|
+
): void {
|
|
30
|
+
'worklet'
|
|
31
|
+
pairCell.value = pair
|
|
32
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// L1's color projection seam (specs/L1-BUILD-PACKET.md §semantics 3): the ONE function that turns a
|
|
2
|
+
// driver progress scalar into the RN backgroundColor string, shared by the animated-style worklet and
|
|
3
|
+
// the deterministic checks so the two paths can never fork (the M2.4 routePanEnd exported-seam
|
|
4
|
+
// pattern). Pure core plumbing — SPRING owns the [0,100] progress convention (REQ-SPRING-013), core
|
|
5
|
+
// value-types own the linear-light mix + byte-exact rgba() projection (REQ-VALUETYPE-006). Runs on
|
|
6
|
+
// the UI runtime (worklet-tagged core dist, the M2.1-proven crossing).
|
|
7
|
+
|
|
8
|
+
import { mixColor, transformColor, type RGBA } from '@unrulysystems/native-motion-core'
|
|
9
|
+
|
|
10
|
+
// progress rides the driver's [0, 100] internal convention; the mix seam consumes a [0, 1] fraction.
|
|
11
|
+
export function projectColor(from: RGBA, to: RGBA, progress: number): string {
|
|
12
|
+
'worklet'
|
|
13
|
+
return transformColor(mixColor(from, to, progress / 100))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// The SEQUENCE projection for a color keyframe array (R8 M2, review major 35): the driver drives a
|
|
17
|
+
// numeric PROGRESS keyframe array [0 … 100] so the shipped keyframe generator owns offsets/per-segment
|
|
18
|
+
// easing; this maps the live progress to a position through the RGBA sequence, then mixColors the
|
|
19
|
+
// adjacent pair for that segment. A 2-color sequence reduces EXACTLY to `projectColor`
|
|
20
|
+
// (position = progress/100, segment 0). The sequence is length ≥2 (validated upstream).
|
|
21
|
+
export function projectColorSequence(sequence: readonly RGBA[], progress: number): string {
|
|
22
|
+
'worklet'
|
|
23
|
+
const lastSegment = sequence.length - 2 // ≥0 for a length-≥2 sequence
|
|
24
|
+
const position = (progress / 100) * (sequence.length - 1)
|
|
25
|
+
const floored = Math.floor(position)
|
|
26
|
+
const segment = floored < 0 ? 0 : floored > lastSegment ? lastSegment : floored
|
|
27
|
+
return transformColor(mixColor(sequence[segment]!, sequence[segment + 1]!, position - segment))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The RGBA the sequence renders at `progress` (the un-projected sibling of `projectColorSequence`):
|
|
31
|
+
// the JS-side re-seed of a color UPDATE reads the live color THROUGH the sequence (major 35), replacing
|
|
32
|
+
// the old two-endpoint `mixColor(from, to, …)`. Same segment math; returns the mixed RGBA, not a string.
|
|
33
|
+
export function sampleColorSequence(sequence: readonly RGBA[], progress: number): RGBA {
|
|
34
|
+
const lastSegment = sequence.length - 2 // ≥0 for a length-≥2 sequence
|
|
35
|
+
const position = (progress / 100) * (sequence.length - 1)
|
|
36
|
+
const floored = Math.floor(position)
|
|
37
|
+
const segment = floored < 0 ? 0 : floored > lastSegment ? lastSegment : floored
|
|
38
|
+
return mixColor(sequence[segment]!, sequence[segment + 1]!, position - segment)
|
|
39
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { scheduleOnRN } from 'react-native-worklets'
|
|
2
|
+
import {
|
|
3
|
+
buildTransformTemplateArgs,
|
|
4
|
+
parseTransformTemplateOutput,
|
|
5
|
+
type TransformTemplate,
|
|
6
|
+
} from '@unrulysystems/native-motion-core'
|
|
7
|
+
import { deferUiOriginatedCallback } from '../driver/workletDriver'
|
|
8
|
+
import { isMotionValue } from './styleValueBinding'
|
|
9
|
+
import {
|
|
10
|
+
NATIVE_TRANSFORM_ORDER,
|
|
11
|
+
TRANSFORM_ANGLE_KEYS,
|
|
12
|
+
type MappedTransformKey,
|
|
13
|
+
} from './transformOrder'
|
|
14
|
+
|
|
15
|
+
// REQ-DRIVER-028 — canonical transform composition order. The order/angle DATA lives in
|
|
16
|
+
// transformOrder.ts (RN-import-free, so the U5-W3 transform-string parser shares the single
|
|
17
|
+
// authority without dragging react-native-worklets into its graph); this module stays the
|
|
18
|
+
// runtime owner — the composite RN `transform` array is built in pinned Motion's order so
|
|
19
|
+
// authoring order never changes the visual result. Re-exported here so every existing consumer
|
|
20
|
+
// (MotionView, layoutTransition, the registry cross-check test) keeps one import site.
|
|
21
|
+
export { NATIVE_TRANSFORM_ORDER, TRANSFORM_ANGLE_KEYS, type MappedTransformKey }
|
|
22
|
+
|
|
23
|
+
// The RN transform entry name differs from the Motion target key only for the translations.
|
|
24
|
+
const RN_ENTRY_NAME: Record<MappedTransformKey, string> = Object.freeze({
|
|
25
|
+
perspective: 'perspective',
|
|
26
|
+
x: 'translateX',
|
|
27
|
+
y: 'translateY',
|
|
28
|
+
scale: 'scale',
|
|
29
|
+
scaleX: 'scaleX',
|
|
30
|
+
scaleY: 'scaleY',
|
|
31
|
+
rotate: 'rotate',
|
|
32
|
+
rotateX: 'rotateX',
|
|
33
|
+
rotateY: 'rotateY',
|
|
34
|
+
skewX: 'skewX',
|
|
35
|
+
skewY: 'skewY',
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
// Plain-object lookups (not Sets): worklet captures stay in the most conservative shareable
|
|
39
|
+
// shape — records of primitives — so serialization can never be the failure mode on the UI runtime.
|
|
40
|
+
const ANGLE_KEY_INDEX: Record<string, 1> = Object.freeze(
|
|
41
|
+
Object.fromEntries(TRANSFORM_ANGLE_KEYS.map((key) => [key, 1 as const])),
|
|
42
|
+
)
|
|
43
|
+
const ORDER_INDEX: Record<string, number> = Object.freeze(
|
|
44
|
+
Object.fromEntries(NATIVE_TRANSFORM_ORDER.map((key, index) => [key, index])),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
// Membership probe for MotionView's animated-style worklet: routes a mounted key to the transform
|
|
48
|
+
// composite vs the plain style path without hand-listing keys at the call site.
|
|
49
|
+
export function isMappedTransformKey(key: string): key is MappedTransformKey {
|
|
50
|
+
'worklet'
|
|
51
|
+
return ORDER_INDEX[key] !== undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Layout's Reanimated-owned frames must name transform entries exactly like the ordinary
|
|
55
|
+
// animated-style lane. Keeping the translation aliases here prevents a layout/property flight
|
|
56
|
+
// from producing `{ x }` or `{ y }`, which React Native does not recognize as transform entries.
|
|
57
|
+
export function nativeTransformEntryName(key: MappedTransformKey): string {
|
|
58
|
+
'worklet'
|
|
59
|
+
return RN_ENTRY_NAME[key]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Layout's host-proxy mirror is another producer of the same RN transform array as the ordinary
|
|
64
|
+
* animated-style lane. Keeping its ordering lookup here prevents an authoring-order divergence
|
|
65
|
+
* between the two producers (REQ-DRIVER-028).
|
|
66
|
+
*/
|
|
67
|
+
export function compareMappedTransformKeys(a: MappedTransformKey, b: MappedTransformKey): number {
|
|
68
|
+
'worklet'
|
|
69
|
+
return ORDER_INDEX[a]! - ORDER_INDEX[b]!
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Pure and workletized: MotionView's animated style calls this on the UI runtime every frame; unit
|
|
73
|
+
// tests call it as a plain function. Captures are frozen plain data only (order table, name map,
|
|
74
|
+
// angle set) — no cross-module calls cross the worklet boundary.
|
|
75
|
+
|
|
76
|
+
/** Family-5 gate result: finite numeric seeds vs mapped keys that must be severity-refused + stripped. */
|
|
77
|
+
export type StaticTransformStyleGate = {
|
|
78
|
+
readonly seeds: Readonly<Record<string, number>>
|
|
79
|
+
readonly refusedKeys: readonly MappedTransformKey[]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Family-5: classify every authored mapped transform shorthand on a flattened style bag.
|
|
84
|
+
* Finite numbers seed composeTransform; exact public MotionValues leave for the binding lane
|
|
85
|
+
* (full surface, same predicate as styleValueBinding); any other present value is refused
|
|
86
|
+
* (NaN / Infinity / string / near-MotionValue / opaque) so the host never keeps a silent
|
|
87
|
+
* RN-dropped residual.
|
|
88
|
+
*/
|
|
89
|
+
export function gateStaticTransformStyle(
|
|
90
|
+
style: Readonly<Record<string, unknown>>,
|
|
91
|
+
): StaticTransformStyleGate {
|
|
92
|
+
const seeds: Record<string, number> = {}
|
|
93
|
+
const refusedKeys: MappedTransformKey[] = []
|
|
94
|
+
for (let i = 0; i < NATIVE_TRANSFORM_ORDER.length; i++) {
|
|
95
|
+
const key = NATIVE_TRANSFORM_ORDER[i]!
|
|
96
|
+
if (!Object.prototype.hasOwnProperty.call(style, key)) continue
|
|
97
|
+
const value = style[key]
|
|
98
|
+
// Full MotionValue only — partial duck-types are refused (not left for a crashing attach).
|
|
99
|
+
if (isMotionValue(value)) continue
|
|
100
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
101
|
+
seeds[key] = value
|
|
102
|
+
} else {
|
|
103
|
+
refusedKeys.push(key)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return { seeds, refusedKeys }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Family-5: extract finite numeric Motion transform shorthands from a flattened style record.
|
|
111
|
+
* Pure JS (not a worklet) — called at render to seed composeTransform for static style transforms.
|
|
112
|
+
*/
|
|
113
|
+
export function extractStaticTransformSeeds(
|
|
114
|
+
style: Readonly<Record<string, unknown>>,
|
|
115
|
+
): Readonly<Record<string, number>> {
|
|
116
|
+
return gateStaticTransformStyle(style).seeds
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function composeTransform(
|
|
120
|
+
values: Readonly<Record<string, number>>,
|
|
121
|
+
): Record<string, number | string>[] {
|
|
122
|
+
'worklet'
|
|
123
|
+
const authored = Object.keys(values)
|
|
124
|
+
const present: string[] = []
|
|
125
|
+
let pathRotation = 0
|
|
126
|
+
for (let i = 0; i < authored.length; i++) {
|
|
127
|
+
const key = authored[i]!
|
|
128
|
+
if (key === 'pathRotation') {
|
|
129
|
+
pathRotation = values.pathRotation ?? 0
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
if (ORDER_INDEX[key] === undefined) {
|
|
133
|
+
// The unmapped-key path is a driver-registry skew surfaced at the animated-style worklet —
|
|
134
|
+
// an operating condition on a LIVE UI callback, never an invariant worth a UI throw (seal
|
|
135
|
+
// r7, F1, iOS AUTO SIGABRT campaign): the key is REFUSED from the output and reported once
|
|
136
|
+
// per key per app run through the scheduleOnRN severity channel. The UI-global dedupe is
|
|
137
|
+
// required because module state is cloned per worklet materialization and never persists.
|
|
138
|
+
const refusalGlobal = globalThis as UnmappedTransformKeyRefusalUiGlobal
|
|
139
|
+
if (refusalGlobal.__nativeMotionUnmappedTransformKeyRefusals === undefined)
|
|
140
|
+
refusalGlobal.__nativeMotionUnmappedTransformKeyRefusals = {}
|
|
141
|
+
if (refusalGlobal.__nativeMotionUnmappedTransformKeyRefusals[key] === undefined) {
|
|
142
|
+
refusalGlobal.__nativeMotionUnmappedTransformKeyRefusals[key] = true
|
|
143
|
+
scheduleOnRN(reportUnmappedTransformKeyRefusal, key)
|
|
144
|
+
}
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
present.push(key)
|
|
148
|
+
}
|
|
149
|
+
present.sort((a, b) => ORDER_INDEX[a]! - ORDER_INDEX[b]!)
|
|
150
|
+
const out: Record<string, number | string>[] = []
|
|
151
|
+
for (let i = 0; i < present.length; i++) {
|
|
152
|
+
const key = present[i] as MappedTransformKey
|
|
153
|
+
const value = values[key]!
|
|
154
|
+
out.push({
|
|
155
|
+
[nativeTransformEntryName(key)]: ANGLE_KEY_INDEX[key] === 1 ? `${value}deg` : value,
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
// U8 (REQ-API-059): pin composes pathRotation as a separate additive rotate after the user's
|
|
159
|
+
// rotate so concurrent rotate is never clobbered. Skip when zero (pin `if (pathRotation)`).
|
|
160
|
+
if (pathRotation) out.push({ rotate: `${pathRotation}deg` })
|
|
161
|
+
return out
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
interface UnmappedTransformKeyRefusalUiGlobal {
|
|
165
|
+
__nativeMotionUnmappedTransformKeyRefusals?: Record<string, true>
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* T17 (specs/T17-TRANSFORM-TEMPLATE-BUILD-PACKET.md floor 3): the per-frame template path. The
|
|
170
|
+
* branded-worklet template is called ON THE UI RUNTIME with the pin's two args (typed values map
|
|
171
|
+
* with defaults included, generated string with defaults excluded); its return string parses into
|
|
172
|
+
* the RN transform array in authored order — prepend/append/reorder all expressible. A throwing
|
|
173
|
+
* template or an off-grammar return is a severity refusal on a LIVE UI callback (never a UI
|
|
174
|
+
* throw — a terminate on device): the template's output is refused, the canonical composition
|
|
175
|
+
* paints, and the failure reports ONCE per component through scheduleOnRN (the unmapped-key
|
|
176
|
+
* precedent: UI-global dedupe because module state is cloned per worklet materialization).
|
|
177
|
+
*/
|
|
178
|
+
// The refusal ledger cap (review minor 1) — mirrors the projection-radius ledger's law. DECLARED
|
|
179
|
+
// ABOVE its reader: `composeTransformWithTemplate` is a worklet, and a worklet's captured-argument
|
|
180
|
+
// object is evaluated at its own declaration position. Sitting below the reader, this const was
|
|
181
|
+
// captured as `undefined`, so `ids.length >= undefined` was always false and the eviction this
|
|
182
|
+
// bound exists to trigger never ran — the ledger grew without limit while its comment claimed it
|
|
183
|
+
// was "bounded like every cache". Pinned by check-worklet-forward-refs.
|
|
184
|
+
const MAX_TRANSFORM_TEMPLATE_REFUSAL_IDS = 256
|
|
185
|
+
|
|
186
|
+
export function composeTransformWithTemplate(
|
|
187
|
+
values: Readonly<Record<string, number>>,
|
|
188
|
+
template: TransformTemplate,
|
|
189
|
+
componentId: string,
|
|
190
|
+
): Record<string, number | string>[] {
|
|
191
|
+
'worklet'
|
|
192
|
+
// Driver-registry skew must never vanish silently because a template owns composition
|
|
193
|
+
// (review minor 2): surface unmapped keys through the SAME refusal channel as the canonical
|
|
194
|
+
// lane and keep them out of the template args (no typeable unit exists for them).
|
|
195
|
+
const mapped: Record<string, number> = {}
|
|
196
|
+
const authored = Object.keys(values)
|
|
197
|
+
for (let i = 0; i < authored.length; i++) {
|
|
198
|
+
const key = authored[i]!
|
|
199
|
+
if (key === 'pathRotation') continue
|
|
200
|
+
if (ORDER_INDEX[key] === undefined) {
|
|
201
|
+
const refusalGlobal = globalThis as UnmappedTransformKeyRefusalUiGlobal
|
|
202
|
+
if (refusalGlobal.__nativeMotionUnmappedTransformKeyRefusals === undefined)
|
|
203
|
+
refusalGlobal.__nativeMotionUnmappedTransformKeyRefusals = {}
|
|
204
|
+
if (refusalGlobal.__nativeMotionUnmappedTransformKeyRefusals[key] === undefined) {
|
|
205
|
+
refusalGlobal.__nativeMotionUnmappedTransformKeyRefusals[key] = true
|
|
206
|
+
scheduleOnRN(reportUnmappedTransformKeyRefusal, key)
|
|
207
|
+
}
|
|
208
|
+
continue
|
|
209
|
+
}
|
|
210
|
+
mapped[key] = values[key]!
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
const args = buildTransformTemplateArgs(mapped)
|
|
214
|
+
return parseTransformTemplateOutput(template(args.values, args.generated))
|
|
215
|
+
} catch (error) {
|
|
216
|
+
const refusalGlobal = globalThis as TransformTemplateRefusalUiGlobal
|
|
217
|
+
if (refusalGlobal.__nativeMotionTransformTemplateRefusals === undefined)
|
|
218
|
+
refusalGlobal.__nativeMotionTransformTemplateRefusals = {}
|
|
219
|
+
const ledger = refusalGlobal.__nativeMotionTransformTemplateRefusals
|
|
220
|
+
if (ledger[componentId] === undefined) {
|
|
221
|
+
// Bounded like every cache (review minor 1): insertion-ordered eviction past the cap —
|
|
222
|
+
// the OLDEST episode's entry drops (it reports again if still live), the newest always
|
|
223
|
+
// fires. Runs only on a first-fire per id, never per frame.
|
|
224
|
+
const ids = Object.keys(ledger)
|
|
225
|
+
if (ids.length >= MAX_TRANSFORM_TEMPLATE_REFUSAL_IDS) delete ledger[ids[0]!]
|
|
226
|
+
ledger[componentId] = true
|
|
227
|
+
scheduleOnRN(
|
|
228
|
+
reportTransformTemplateRefusal,
|
|
229
|
+
componentId,
|
|
230
|
+
error instanceof Error ? error.message : String(error),
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
return composeTransform(values)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
interface TransformTemplateRefusalUiGlobal {
|
|
238
|
+
__nativeMotionTransformTemplateRefusals?: Record<string, true>
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// The RN-side severity-law reporter for a template failure (T17): one JS task out of the
|
|
242
|
+
// scheduleOnRN dispatch extent — development throws; production reports through the error
|
|
243
|
+
// channel while the canonical composition paints (the template's output is refused). The UI
|
|
244
|
+
// side never throws.
|
|
245
|
+
function reportTransformTemplateRefusal(componentId: string, message: string): void {
|
|
246
|
+
const error = new Error(
|
|
247
|
+
`${componentId}: transformTemplate failed and its output was refused — the canonical ` +
|
|
248
|
+
`transform composition painted instead (T17). Cause: ${message}`,
|
|
249
|
+
)
|
|
250
|
+
deferUiOriginatedCallback(() => {
|
|
251
|
+
if (typeof __DEV__ === 'undefined' || __DEV__ !== false) throw error
|
|
252
|
+
console.error(error)
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// The RN-side severity-law reporter for a driver-mapped-key violation (seal r7, F1): delivered
|
|
257
|
+
// one JS task out of the scheduleOnRN dispatch extent (the seal-r3 seam) — development throws;
|
|
258
|
+
// production reports through the error channel while the key is refused from the transform
|
|
259
|
+
// output. The UI side never throws (a bare std::terminate on device).
|
|
260
|
+
declare const __DEV__: boolean | undefined
|
|
261
|
+
|
|
262
|
+
function reportUnmappedTransformKeyRefusal(key: string): void {
|
|
263
|
+
const error = new Error(
|
|
264
|
+
`composeTransform: transform key '${key}' is not driver-mapped (REQ-DRIVER-027/-028) — ` +
|
|
265
|
+
'refused from the transform output (iOS AUTO SIGABRT campaign).',
|
|
266
|
+
)
|
|
267
|
+
deferUiOriginatedCallback(() => {
|
|
268
|
+
if (typeof __DEV__ === 'undefined' || __DEV__ !== false) throw error
|
|
269
|
+
console.error(error)
|
|
270
|
+
})
|
|
271
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// REQ-GESTURE-028 / REQ-API-042 (R15 D2) — the grab-time constraint MEASURE seam. The pin
|
|
2
|
+
// reads both page boxes synchronously when it resolves (a cache miss or its own
|
|
3
|
+
// measure/resize invalidation machinery); native has no synchronous page-box read, so
|
|
4
|
+
// MotionView's wiring measures asynchronously at EVERY grab (the freshness superset) and the
|
|
5
|
+
// wiring defers begin until the boxes land. The draggable's LAYOUT box is the visual frame
|
|
6
|
+
// MINUS the live drag value per dragged axis (the pin's projection.layout.layoutBox is
|
|
7
|
+
// transform-independent; the drag value applies on top of layout, so layout = visual − value).
|
|
8
|
+
// The constraint element is anything exposing measureInWindow (a mounted host ref); the pin's
|
|
9
|
+
// invariant fails loudly when it doesn't resolve. Errors here carry NO component prefix —
|
|
10
|
+
// ownership lands at exactly one boundary (MotionView's componentDragError, review r11
|
|
11
|
+
// minor 1).
|
|
12
|
+
|
|
13
|
+
import { describeValue, type Driver, type ElementHandle } from '@unrulysystems/native-motion-core'
|
|
14
|
+
import type { DragAxis } from '@unrulysystems/native-motion-core'
|
|
15
|
+
import type { ConstraintsMeasure, ConstraintsMeasureBoxes } from './dragGestureWiring'
|
|
16
|
+
|
|
17
|
+
/** Safe reject payload — never throws on hostile throwables (Object.create(null)). */
|
|
18
|
+
function asRejectError(error: unknown): Error {
|
|
19
|
+
if (error instanceof Error) return error
|
|
20
|
+
// describeValue is total over revoked proxies / null-prototype objects (core formatter law).
|
|
21
|
+
return new Error(describeValue(error))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The pin's invariant surface (VEDC "drag-constraints-ref"): a ref whose `.current` is not a
|
|
25
|
+
// mounted measurable element at drag start is a loud failure, never a mount-time guess and
|
|
26
|
+
// never a silent unconstrained grab.
|
|
27
|
+
export function isMeasurableConstraintTarget(target: unknown): target is {
|
|
28
|
+
measureInWindow(callback: (x: number, y: number, width: number, height: number) => void): void
|
|
29
|
+
} {
|
|
30
|
+
return (
|
|
31
|
+
typeof target === 'object' &&
|
|
32
|
+
target !== null &&
|
|
33
|
+
'measureInWindow' in target &&
|
|
34
|
+
typeof (target as { measureInWindow?: unknown }).measureInWindow === 'function'
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface MeasurableHost {
|
|
39
|
+
measureInWindow(callback: (x: number, y: number, width: number, height: number) => void): void
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Build the wiring's measure seam over the draggable's own measurable host ref, the driver
|
|
43
|
+
// (for the live per-axis values), and the element handle. `axes` are the element's DRAGGED
|
|
44
|
+
// axes: a live driver value exists only on those — an axis with no drag channel has no driver
|
|
45
|
+
// registration at all (liveFor on it THROWS, REQ-DRIVER-025), and its drag value is zero, so
|
|
46
|
+
// its layout span IS the visual span (the pin's transform-independent layoutBox analog).
|
|
47
|
+
export function createConstraintMeasure(options: {
|
|
48
|
+
readonly driver: Driver
|
|
49
|
+
readonly handle: ElementHandle
|
|
50
|
+
readonly axes: readonly DragAxis[]
|
|
51
|
+
readonly hostRef: () => MeasurableHost | null
|
|
52
|
+
/**
|
|
53
|
+
* REQ-API-043 page-point correction at the measure ingest boundary (transformPagePoint).
|
|
54
|
+
* Applied to measured window boxes so ref constraints under transformed ancestors match
|
|
55
|
+
* the pin's page-space math.
|
|
56
|
+
*/
|
|
57
|
+
readonly transformPagePoint?:
|
|
58
|
+
| ((point: { readonly x: number; readonly y: number }) => {
|
|
59
|
+
readonly x: number
|
|
60
|
+
readonly y: number
|
|
61
|
+
})
|
|
62
|
+
| undefined
|
|
63
|
+
}): ConstraintsMeasure {
|
|
64
|
+
const { driver, handle, axes, hostRef, transformPagePoint } = options
|
|
65
|
+
const mapBox = (
|
|
66
|
+
x: number,
|
|
67
|
+
y: number,
|
|
68
|
+
width: number,
|
|
69
|
+
height: number,
|
|
70
|
+
): { readonly min: number; readonly max: number } => {
|
|
71
|
+
if (transformPagePoint === undefined) {
|
|
72
|
+
return { min: x, max: x + width }
|
|
73
|
+
}
|
|
74
|
+
// Corner-transform so scale/offset ancestors correct both position and extent.
|
|
75
|
+
const tl = transformPagePoint({ x, y })
|
|
76
|
+
const br = transformPagePoint({ x: x + width, y: y + height })
|
|
77
|
+
return { min: tl.x, max: br.x }
|
|
78
|
+
}
|
|
79
|
+
const mapBoxY = (
|
|
80
|
+
x: number,
|
|
81
|
+
y: number,
|
|
82
|
+
width: number,
|
|
83
|
+
height: number,
|
|
84
|
+
): { readonly min: number; readonly max: number } => {
|
|
85
|
+
if (transformPagePoint === undefined) {
|
|
86
|
+
return { min: y, max: y + height }
|
|
87
|
+
}
|
|
88
|
+
const tl = transformPagePoint({ x, y })
|
|
89
|
+
const br = transformPagePoint({ x: x + width, y: y + height })
|
|
90
|
+
return { min: tl.y, max: br.y }
|
|
91
|
+
}
|
|
92
|
+
return (container) =>
|
|
93
|
+
new Promise<ConstraintsMeasureBoxes>((resolve, reject) => {
|
|
94
|
+
const target = container.current
|
|
95
|
+
if (!isMeasurableConstraintTarget(target)) {
|
|
96
|
+
reject(
|
|
97
|
+
new Error(
|
|
98
|
+
"a dragConstraints ref must be passed to another component's ref " +
|
|
99
|
+
'prop — the constraint element is unmounted or not measurable at drag start ' +
|
|
100
|
+
'(REQ-API-042; the pin invariant, VEDC "drag-constraints-ref").',
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
const host = hostRef()
|
|
106
|
+
if (host === null) {
|
|
107
|
+
reject(new Error("the draggable's host is not measurable at drag start (REQ-API-042)."))
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
// The pin reads the draggable's LAYOUT box (transform-independent): the visual frame
|
|
111
|
+
// minus the live drag value per DRAGGED axis (layout = visual − value; the session's
|
|
112
|
+
// value applies on top of layout; a non-dragged axis carries no drag value at all).
|
|
113
|
+
// The live read happens INSIDE the measure callback (review r5 major 2): the delivered
|
|
114
|
+
// frame and the live value must pair at the same instant — reading live at dispatch would
|
|
115
|
+
// inject the UI-measure latency as a per-frame jitter into the layout box. The callbacks
|
|
116
|
+
// run OUTSIDE the Promise executor's scope, so a callback-time failure (a disposed driver
|
|
117
|
+
// after unmount — review r8 major 2) must REJECT explicitly, never escape raw and leave
|
|
118
|
+
// the measurement pending forever.
|
|
119
|
+
let draggableBox: ConstraintsMeasureBoxes['draggableBox'] | null = null
|
|
120
|
+
let containerBox: ConstraintsMeasureBoxes['containerBox'] | null = null
|
|
121
|
+
const commit = (): void => {
|
|
122
|
+
if (draggableBox !== null && containerBox !== null) {
|
|
123
|
+
resolve({ draggableBox, containerBox })
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
host.measureInWindow((x, y, width, height) => {
|
|
127
|
+
try {
|
|
128
|
+
const liveX = axes.includes('x') ? driver.liveFor(handle, 'x').value : 0
|
|
129
|
+
const liveY = axes.includes('y') ? driver.liveFor(handle, 'y').value : 0
|
|
130
|
+
const mappedX = mapBox(x, y, width, height)
|
|
131
|
+
const mappedY = mapBoxY(x, y, width, height)
|
|
132
|
+
draggableBox = {
|
|
133
|
+
x: { min: mappedX.min - liveX, max: mappedX.max - liveX },
|
|
134
|
+
y: { min: mappedY.min - liveY, max: mappedY.max - liveY },
|
|
135
|
+
}
|
|
136
|
+
commit()
|
|
137
|
+
} catch (error) {
|
|
138
|
+
// transformPagePoint / live read may throw — reject so the deferred grab is not
|
|
139
|
+
// stranded (G-INV-7). Never String(error) on hostile throwables (seal review r6 major).
|
|
140
|
+
reject(asRejectError(error))
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
target.measureInWindow((x, y, width, height) => {
|
|
144
|
+
try {
|
|
145
|
+
containerBox = {
|
|
146
|
+
x: mapBox(x, y, width, height),
|
|
147
|
+
y: mapBoxY(x, y, width, height),
|
|
148
|
+
}
|
|
149
|
+
commit()
|
|
150
|
+
} catch (error) {
|
|
151
|
+
// transformPagePoint may throw — reject so the deferred grab is not stranded
|
|
152
|
+
// (G-INV-7; seal review r3 major / r6 major String-safety).
|
|
153
|
+
reject(asRejectError(error))
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// REQ-VALUETYPE-013 — split command vs deferred-pending animate snapshots under gesture hold.
|
|
2
|
+
// review-1785228898857-1thixx: full-scope pending must see the declarative authority (including
|
|
3
|
+
// held keys still in animate); commands must exclude held keys (protectedKeys).
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `command` — keys safe to retarget now (not gesture-held).
|
|
7
|
+
* `deferredPending` — full declarative animate authority for pending abandon/rebuild (never
|
|
8
|
+
* held-filtered; absence means product removed the key from animate).
|
|
9
|
+
*/
|
|
10
|
+
export function splitCommandAndDeferredAnimateSnapshots(
|
|
11
|
+
declarativeAnimate: Readonly<Record<string, unknown>>,
|
|
12
|
+
held: ReadonlySet<string>,
|
|
13
|
+
): {
|
|
14
|
+
readonly command: Record<string, unknown>
|
|
15
|
+
readonly deferredPending: Record<string, unknown>
|
|
16
|
+
} {
|
|
17
|
+
const command: Record<string, unknown> = {}
|
|
18
|
+
for (const key of Object.keys(declarativeAnimate)) {
|
|
19
|
+
if (!held.has(key)) command[key] = declarativeAnimate[key]
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
command,
|
|
23
|
+
deferredPending: { ...declarativeAnimate },
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// The discrete keyword projection seam (T23 B, packet design law B) — the projectColorSequence
|
|
2
|
+
// pattern: the driver animates a [0, 100] progress channel in a shared value; the animated-style
|
|
3
|
+
// worklet projects the committed keyword from per-key endpoint shared values each frame. This is
|
|
4
|
+
// the ONE projection both the style worklet and deterministic checks call, parity-bound to core
|
|
5
|
+
// mixDiscrete by discreteProjection.test.ts so the two statements of the law — both of its paths,
|
|
6
|
+
// the mixVisibility step and the zero-slot constant — can never fork.
|
|
7
|
+
//
|
|
8
|
+
// The law is RESTATED inline rather than calling mixDiscrete because a worklet capture must stay
|
|
9
|
+
// primitive — no captured Set, so invisible-set membership is checked inline. Both statements are
|
|
10
|
+
// bound together by discreteProjection.test.ts over every pair class, so they cannot fork.
|
|
11
|
+
//
|
|
12
|
+
// ONE deliberate asymmetry: `mixDiscrete` refuses an endpoint that is not a discrete keyword, and
|
|
13
|
+
// this does not. A worklet must be TOTAL — a UI-runtime throw is banned (check:no-ui-throw) — and
|
|
14
|
+
// the controller only ever registers keyword pairs, so the guard belongs at the exported core
|
|
15
|
+
// symbol a caller can reach directly, not on the frame path. The parity binding covers the keyword
|
|
16
|
+
// family, which is the only family that reaches here.
|
|
17
|
+
//
|
|
18
|
+
// H3: a both-visible pair used to be refused before registration, and this worklet's final arm
|
|
19
|
+
// doubled as a total fallback for it — holding the ORIGIN, which is not what the pin does. That
|
|
20
|
+
// pair is now legal and reaches here, so the constant arm is explicit rather than incidental.
|
|
21
|
+
export function projectDiscreteStep(from: string, to: string, progress: number): string {
|
|
22
|
+
'worklet'
|
|
23
|
+
if (from === 'none' || from === 'hidden') {
|
|
24
|
+
// Invisible ORIGIN appears at start: origin only while p <= 0 (the pin's origin arm — a
|
|
25
|
+
// both-invisible pair takes this arm first, exactly like mixDiscrete).
|
|
26
|
+
return progress <= 0 ? from : to
|
|
27
|
+
}
|
|
28
|
+
if (to === 'none' || to === 'hidden') {
|
|
29
|
+
// Invisible TARGET disappears at end: target only once p >= 100 (the internal channel is
|
|
30
|
+
// [0, 100]; core's mixDiscrete sees the same instant as p >= 1).
|
|
31
|
+
return progress >= 100 ? to : from
|
|
32
|
+
}
|
|
33
|
+
// Both visible: the pin's zero-slot template publishes the TARGET at every progress, negative
|
|
34
|
+
// and zero included. Not a step — nothing here reads `progress` at all.
|
|
35
|
+
return to
|
|
36
|
+
}
|