@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.
Files changed (117) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +55 -0
  4. package/android/build.gradle +24 -0
  5. package/android/src/main/AndroidManifest.xml +1 -0
  6. package/android/src/main/java/com/unrulysystems/nativemotion/GestureExclusionModule.kt +55 -0
  7. package/android/src/main/java/com/unrulysystems/nativemotion/NativeMotionPackage.kt +27 -0
  8. package/package.json +57 -0
  9. package/react-native.config.js +14 -0
  10. package/src/native/driver/instantWindow.ts +31 -0
  11. package/src/native/driver/strictModeReplay.ts +20 -0
  12. package/src/native/driver/uiLayoutEngine.ts +845 -0
  13. package/src/native/driver/uiLayoutGraph.ts +338 -0
  14. package/src/native/driver/uiValueChannel.ts +4640 -0
  15. package/src/native/driver/workletDriver.ts +3860 -0
  16. package/src/native/motion/AnimatePresence.tsx +1605 -0
  17. package/src/native/motion/LayoutGroup.tsx +163 -0
  18. package/src/native/motion/MotionConfig.tsx +400 -0
  19. package/src/native/motion/MotionRoot.tsx +250 -0
  20. package/src/native/motion/MotionView.tsx +6761 -0
  21. package/src/native/motion/addScaleCorrector.ts +97 -0
  22. package/src/native/motion/colorEndpointUiFeed.ts +32 -0
  23. package/src/native/motion/colorProjection.ts +39 -0
  24. package/src/native/motion/composeTransform.ts +271 -0
  25. package/src/native/motion/constraintMeasure.ts +157 -0
  26. package/src/native/motion/deferredPendingSnapshots.ts +25 -0
  27. package/src/native/motion/discreteProjection.ts +36 -0
  28. package/src/native/motion/dragAncestorPanContext.ts +28 -0
  29. package/src/native/motion/dragControls.ts +152 -0
  30. package/src/native/motion/dragGestureWiring.ts +1168 -0
  31. package/src/native/motion/dragHandoffBinding.ts +179 -0
  32. package/src/native/motion/dragHubStream.ts +756 -0
  33. package/src/native/motion/dragPropagationLock.ts +57 -0
  34. package/src/native/motion/driverValueChannel.ts +3288 -0
  35. package/src/native/motion/externalDragDriver.ts +214 -0
  36. package/src/native/motion/frameData.ts +31 -0
  37. package/src/native/motion/gestureBinding.ts +107 -0
  38. package/src/native/motion/gestureStateGate.ts +563 -0
  39. package/src/native/motion/gestureStateResolver.ts +286 -0
  40. package/src/native/motion/identityValueChannelControllerAdapter.ts +2043 -0
  41. package/src/native/motion/identityValueChannelControllerReconciliation.ts +176 -0
  42. package/src/native/motion/identityValueChannelLaneMarker.ts +14 -0
  43. package/src/native/motion/imperativeAnimate.ts +816 -0
  44. package/src/native/motion/keyframeTiming.ts +7 -0
  45. package/src/native/motion/layoutIdBinding.ts +261 -0
  46. package/src/native/motion/layoutIdFlightConfig.ts +51 -0
  47. package/src/native/motion/layoutProjection.ts +89 -0
  48. package/src/native/motion/layoutScroll.ts +204 -0
  49. package/src/native/motion/layoutTransition.ts +1302 -0
  50. package/src/native/motion/lengthLayoutContext.tsx +100 -0
  51. package/src/native/motion/lengthLayoutHost.ts +44 -0
  52. package/src/native/motion/mappedKeys.ts +184 -0
  53. package/src/native/motion/motionViewController.ts +3180 -0
  54. package/src/native/motion/nativeHostMarker.ts +22 -0
  55. package/src/native/motion/panSessionWiring.ts +127 -0
  56. package/src/native/motion/pathTransition.ts +101 -0
  57. package/src/native/motion/popLayout.ts +176 -0
  58. package/src/native/motion/presenceBinding.ts +382 -0
  59. package/src/native/motion/scaleCorrectorRegistry.ts +123 -0
  60. package/src/native/motion/scrollValues.ts +260 -0
  61. package/src/native/motion/serializablePayload.ts +151 -0
  62. package/src/native/motion/severity.ts +16 -0
  63. package/src/native/motion/shippedSurface.ts +843 -0
  64. package/src/native/motion/staticLengthGate.ts +86 -0
  65. package/src/native/motion/styleBaseGate.ts +72 -0
  66. package/src/native/motion/styleValueBinding.ts +657 -0
  67. package/src/native/motion/systemGestureExclusion.ts +61 -0
  68. package/src/native/motion/tapGestureWiring.ts +215 -0
  69. package/src/native/motion/transformOrder.ts +38 -0
  70. package/src/native/motion/transformStringBinding.ts +200 -0
  71. package/src/native/motion/transformTemplateGate.ts +45 -0
  72. package/src/native/motion/transitionGate.ts +435 -0
  73. package/src/native/motion/useAnimate.ts +52 -0
  74. package/src/native/motion/useCycle.ts +44 -0
  75. package/src/native/motion/useInstantTransition.ts +68 -0
  76. package/src/native/motion/useReducedMotion.ts +58 -0
  77. package/src/native/motion/useScroll.ts +162 -0
  78. package/src/native/motion/useViewportScroll.ts +29 -0
  79. package/src/native/motion/valueChannel.ts +7121 -0
  80. package/src/native/motion/valueHooks.ts +901 -0
  81. package/src/native/motion/variantChildRegistry.ts +67 -0
  82. package/src/native/motion/variantContext.tsx +172 -0
  83. package/src/native/motion/variantProps.ts +598 -0
  84. package/src/native.ts +159 -0
  85. package/src/verification/harnessMetrics.ts +78 -0
  86. package/src/verification/probe/LayoutIdentityWorkletProbe.tsx +251 -0
  87. package/src/verification/probe/WorkletParityProbe.tsx +162 -0
  88. package/src/verification/probe/layoutIdentityProbeEngine.ts +429 -0
  89. package/src/verification/screens/ArcPathChecksScreen.tsx +273 -0
  90. package/src/verification/screens/BooleanAnimateChecksScreen.tsx +358 -0
  91. package/src/verification/screens/ChoreographyGalleryScreen.tsx +1010 -0
  92. package/src/verification/screens/ColorBindingChecksScreen.tsx +568 -0
  93. package/src/verification/screens/CompletionChecksScreen.tsx +294 -0
  94. package/src/verification/screens/ConformanceScreen.tsx +573 -0
  95. package/src/verification/screens/ContentionProbeScreen.tsx +224 -0
  96. package/src/verification/screens/DriverSmokeScreen.tsx +99 -0
  97. package/src/verification/screens/DurationOnlyTweenChecksScreen.tsx +294 -0
  98. package/src/verification/screens/DynamicDragConfigChecksScreen.tsx +237 -0
  99. package/src/verification/screens/FrameDataChecksScreen.tsx +252 -0
  100. package/src/verification/screens/GestureChecksScreen.tsx +685 -0
  101. package/src/verification/screens/InstantTransitionChecksScreen.tsx +839 -0
  102. package/src/verification/screens/LayoutAnimationStartChecksScreen.tsx +648 -0
  103. package/src/verification/screens/LayoutChecksScreen.tsx +822 -0
  104. package/src/verification/screens/LayoutCommitSpikeScreen.tsx +142 -0
  105. package/src/verification/screens/MotionViewChecksScreen.tsx +930 -0
  106. package/src/verification/screens/PresenceChecksScreen.tsx +614 -0
  107. package/src/verification/screens/ReducedMotionChecksScreen.tsx +614 -0
  108. package/src/verification/screens/RestThresholdChecksScreen.tsx +634 -0
  109. package/src/verification/screens/ScaleCorrectorChecksScreen.tsx +425 -0
  110. package/src/verification/screens/SharedLayoutContinuityChecksScreen.tsx +338 -0
  111. package/src/verification/screens/SharedLayoutCrossfadeChecksScreen.tsx +425 -0
  112. package/src/verification/screens/TransitionDefaultSelectionChecksScreen.tsx +514 -0
  113. package/src/verification/screens/ViewportScrollAliasChecksScreen.tsx +303 -0
  114. package/src/verification/screens/conformanceBanner.ts +21 -0
  115. package/src/verification/screens/proofConsoleTap.ts +18 -0
  116. package/src/verification.ts +47 -0
  117. package/src/web.ts +98 -0
@@ -0,0 +1,634 @@
1
+ // U7c rest-threshold device checks (the U7c packet's floor 3): authored restDelta/restSpeed
2
+ // reaching the spring solver's settle condition, exercised as VISIBLY different settle points
3
+ // under authored thresholds. The card mirrors the W2 web oracle
4
+ // (packages/native-motion-web/e2e/u7c-rest-thresholds.e2e.ts) and the pinned catalog
5
+ // useSpring.tsx element form: ONE trigger flies four boxes 0 → 100 on the catalog's
6
+ // stiffness-300 / damping-28 spring — loose (restDelta 80, restSpeed 2000), default (no
7
+ // thresholds), zero (0/0 — the pin's ||= quirk reads explicit 0 as ABSENT, motion-dom
8
+ // spring.ts:275-280), tight (0.00001/0.00001 — the catalog values verbatim) — plus a fifth box
9
+ // on an ordinary 600ms tween carrying the tight thresholds (the pin IGNORES them outside the
10
+ // spring generator — only generators/spring.ts and inertia.ts read them). Four rows:
11
+ //
12
+ // 1. SETTLE ORDERING — the loose box's activity stops measurably EARLIEST, the tight box's
13
+ // measurably LATEST, and the zero box matches the default box within a couple frames (the
14
+ // ||= quirk: explicit 0 rides the pin's default selection). Measured via each box's LAST
15
+ // onUpdate timestamp — the committed-values marshal is the only readback, never a JS-side
16
+ // mirror, and never a fixed frame chain (the 7b lesson): every box's settle is awaited by
17
+ // a fail-closed quiescence poll (quiet for 250ms AND at the target, else a loud deadline),
18
+ // so a box that never animates or never settles fails LOUD with its latest value named.
19
+ // Counterfactual (the W2 web leg's, observed): thresholds dropped or refused anywhere in
20
+ // the forwarding chain collapse every box onto the pin's 2 / 0.5 defaults and all four
21
+ // settle on the same frame — both margins read ~0 and fail.
22
+ // 2. EXACT SNAP — the loose box's AND settle fires mid-flight (restDelta 80 admits
23
+ // |target − current| ≤ 80; restSpeed 2000 never binds — peak |v| ≈ 730px/s), so its last
24
+ // pre-settle onUpdate sample sits FAR from 100 while the committed post-settle value is
25
+ // EXACTLY 100 (the pin's `state.done ? target : current` snap, spring.ts:416-423 — never
26
+ // the in-flight current). An engine that settles on thresholds but commits `current`
27
+ // leaves the box at ~24 and fails the exact read; dropped thresholds settle the loose box
28
+ // at the default ~517ms, where the pre-settle sample reads ~99.5 and fails the
29
+ // far-off-target bound.
30
+ // 3. TWEEN IGNORES THEM (the pin's parity) — { type: 'tween', duration: 0.6, restDelta:
31
+ // 0.00001, restSpeed: 0.00001 } flies the ORDINARY 600ms tween: the thresholds don't
32
+ // refuse (no validation report, the flight runs), don't truncate (mid-flight samples
33
+ // strictly between 0 and 100 exist; a threshold-driven early snap settles ~50ms and fails
34
+ // the class band), and don't mis-settle (the flight completes in its 600ms class, not the
35
+ // tight spring's ~1233ms).
36
+ // 4. CLEAN RUN — zero refusal reports through the console tap (the U5 pattern: console.error
37
+ // is the severity law's production error channel) across the whole timeline.
38
+ //
39
+ // Readback honesty: every x sample rides the element's own onUpdate (the driver's per-frame
40
+ // committed-values marshal) with a JS-receipt timestamp; ORDERING across boxes is the claim
41
+ // (every box rides the same marshal path, so delivery lag is shared), and each landing latency
42
+ // is disclosed as evidence, never gated to a fixed offset. A screenshot is the assertion; the
43
+ // console tap is evidence capture, never suppression.
44
+
45
+ import { useCallback, useEffect, useRef, useState } from 'react'
46
+ import { ScrollView, StyleSheet, Text, View } from 'react-native'
47
+ import { MotionView } from '../../native/motion/MotionView'
48
+ import { ambientNativeSeverity } from '../../native/motion/severity'
49
+
50
+ const ROW_ORDER = 'settle ordering: loose earliest, tight latest, zero ≈ default (the ||= quirk)'
51
+ const ROW_SNAP = 'exact snap: the loose settle fires mid-flight yet commits exactly 100'
52
+ const ROW_TWEEN = 'tween ignores them: the 600ms tween flies its full class with 1e-5 authored'
53
+ const ROW_CLEAN = 'clean run: zero refusal reports through the console tap'
54
+ const ROWS = [ROW_ORDER, ROW_SNAP, ROW_TWEEN, ROW_CLEAN] as const
55
+
56
+ // Every subject flies 0 → 100; the 100px delta keeps every spring in the pin's DEFAULT
57
+ // threshold lane (|target − origin| < 5 is the granular lane — the same code path, pinned
58
+ // natively at unit level by W1).
59
+ const TARGET = 100
60
+ // The catalog's spring constants (useSpring.tsx:10-15).
61
+ const SPRING = { type: 'spring', stiffness: 300, damping: 28 } as const
62
+
63
+ // Committed-x equality margin (device px) for "reached the target"; the EXACT-snap row owns
64
+ // exactness and reads === 100.
65
+ const EPS = 0.5
66
+ // Row 3's flight margin: a mid-flight sample must clear 2px off both endpoints to count.
67
+ const FLIGHT_MARGIN = 2
68
+
69
+ // Row 1 margins (ms), mirroring the W2 web leg: the stepped pinned generator at 60fps settles
70
+ // loose ~50ms, default/zero ~517ms (the identical frame), tight ~1233ms — observed gaps ≈ 467
71
+ // and ≈ 717ms, so both bounds carry >2x margin; the zero-vs-default band absorbs a couple
72
+ // frames of marshal spread (web observed the IDENTICAL frame).
73
+ const LOOSE_EARLY_MARGIN_MS = 200
74
+ const TIGHT_LATE_MARGIN_MS = 400
75
+ const ZERO_DEFAULT_BAND_MS = 50
76
+ // Row 2's far-off-target bound for the loose box's last pre-settle sample (observed ~24px into
77
+ // the flight on the web leg; >2x margin).
78
+ const LOOSE_PRE_SETTLE_BOUND = 50
79
+ // Row 3's tween-class band (ms post-trigger): 600ms nominal plus startup/delivery slack;
80
+ // below the floor a threshold-driven truncation settled early, above the ceiling the flight
81
+ // rode something other than the 600ms tween (the tight spring's class is ~1233ms + delivery).
82
+ const TWEEN_CLASS_FLOOR_MS = 350
83
+ const TWEEN_CLASS_CEILING_MS = 1500
84
+
85
+ // Fail-closed observation: a box is SETTLED when its onUpdate marshal has been quiet for
86
+ // QUIET_MS AND its latest committed value reads the target — a box that never animates, stalls
87
+ // mid-flight, or oscillates forever (the literal-0 counterfactual: |v| ≤ 0 AND
88
+ // |target − current| ≤ 0 is unsatisfiable) hits the deadline and fails LOUD.
89
+ const QUIET_MS = 250
90
+ const SPRING_SETTLE_DEADLINE_MS = 6000
91
+ const TWEEN_SETTLE_DEADLINE_MS = 4000
92
+
93
+ // Timeline (ms). The boot dwell (0–2400) holds every subject at its origin for the first
94
+ // capture; the trigger fires at 2400; rows 1–3 SELF-FINALIZE as their quiescence polls resolve
95
+ // (loose ~+50ms, default/zero ~+517ms, the tween ~+600ms, tight ~+1233ms, each plus the
96
+ // device-dependent marshal delivery — disclosed, never gated); the clean-run verdict lands at
97
+ // 8000 and the final dwell holds the tally from ~8200 on.
98
+ const T_START = 2400
99
+ const T_CLEAN = 8000
100
+ const T_DONE = 8200
101
+
102
+ interface RowResult {
103
+ readonly pass: boolean
104
+ readonly detail: string
105
+ }
106
+
107
+ interface XSample {
108
+ readonly at: number
109
+ readonly value: number
110
+ }
111
+
112
+ interface SubjectTrace {
113
+ latest: number
114
+ // Mount-relative stamp of the most recent onUpdate marshal (-1 = none yet).
115
+ lastSampleAt: number
116
+ readonly samples: XSample[]
117
+ }
118
+
119
+ // A quiescence poll's resolution: the mount-relative stamp of the box's LAST onUpdate sample
120
+ // (the settle point) and the poll's own resolution time — or null when the deadline passed
121
+ // first (never-animates / never-settles both land here and fail loud).
122
+ interface SettleLanding {
123
+ readonly settleAt: number
124
+ readonly resolvedAt: number
125
+ }
126
+
127
+ type SpringKey = 'loose' | 'default' | 'zero' | 'tight'
128
+ type SubjectKey = SpringKey | 'tween'
129
+
130
+ const freshTrace = (): SubjectTrace => ({ latest: 0, lastSampleAt: -1, samples: [] })
131
+
132
+ function freshTraces(): Record<SubjectKey, SubjectTrace> {
133
+ return {
134
+ loose: freshTrace(),
135
+ default: freshTrace(),
136
+ zero: freshTrace(),
137
+ tight: freshTrace(),
138
+ tween: freshTrace(),
139
+ }
140
+ }
141
+
142
+ function readX(latest: Readonly<Record<string, string | number>>): number | null {
143
+ const raw = latest['x']
144
+ const value = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : NaN
145
+ return Number.isFinite(value) ? value : null
146
+ }
147
+
148
+ // The rows 1–3 observation primitive (the 7b lesson: landing polls, never fixed frame
149
+ // chains): poll per rAF until the trace has been QUIET for QUIET_MS with the latest committed
150
+ // value at the target, or the fail-closed deadline passes. The settle point is the last
151
+ // sample's own receipt stamp — measured EVIDENCE, with the deadline the only time gate. Fires
152
+ // `onResolve` exactly once.
153
+ function pollSettle(
154
+ trace: SubjectTrace,
155
+ startStamp: number,
156
+ deadlineAt: number,
157
+ now: () => number,
158
+ trackRaf: (id: ReturnType<typeof requestAnimationFrame>) => void,
159
+ onResolve: (landing: SettleLanding | null) => void,
160
+ ): void {
161
+ const step = (): void => {
162
+ const t = now()
163
+ const flown = trace.lastSampleAt >= startStamp
164
+ const quiet = flown && t - trace.lastSampleAt >= QUIET_MS
165
+ const atTarget = Math.abs(trace.latest - TARGET) <= EPS
166
+ if (quiet && atTarget) {
167
+ onResolve({ settleAt: trace.lastSampleAt, resolvedAt: t })
168
+ return
169
+ }
170
+ if (t >= deadlineAt) {
171
+ onResolve(null)
172
+ return
173
+ }
174
+ trackRaf(requestAnimationFrame(step))
175
+ }
176
+ trackRaf(requestAnimationFrame(step))
177
+ }
178
+
179
+ export function RestThresholdChecksScreen() {
180
+ const severity = ambientNativeSeverity()
181
+
182
+ const [on, setOn] = useState(false)
183
+ const [stage, setStage] = useState('boot: five boxes at their origins (initial={false})')
184
+ const [live, setLive] = useState<Partial<Record<SubjectKey, string>>>({})
185
+ const [rows, setRows] = useState<Partial<Record<(typeof ROWS)[number], RowResult>>>({})
186
+
187
+ const mountStampRef = useRef(Date.now())
188
+ const tracesRef = useRef<Record<SubjectKey, SubjectTrace> | null>(null)
189
+ if (tracesRef.current === null) tracesRef.current = freshTraces()
190
+
191
+ // Row evidence: the trigger stamp and each box's poll resolution (undefined = the poll is
192
+ // still running; null = the deadline passed first).
193
+ const startStampRef = useRef(0)
194
+ const landingsRef = useRef<Partial<Record<SubjectKey, SettleLanding | null>>>({})
195
+
196
+ const rafsRef = useRef<ReturnType<typeof requestAnimationFrame>[]>([])
197
+ const reportsRef = useRef<string[]>([])
198
+
199
+ const trackRaf = (id: ReturnType<typeof requestAnimationFrame>): void => {
200
+ rafsRef.current.push(id)
201
+ }
202
+
203
+ const record = useCallback((key: SubjectKey, value: number): void => {
204
+ const trace = tracesRef.current![key]
205
+ const at = Date.now() - mountStampRef.current
206
+ trace.latest = value
207
+ trace.lastSampleAt = at
208
+ trace.samples.push({ at, value })
209
+ setLive((previous) => ({ ...previous, [key]: value.toFixed(1) }))
210
+ }, [])
211
+
212
+ const finalize = (id: (typeof ROWS)[number], pass: boolean, detail: string): void => {
213
+ setRows((previous) => ({ ...previous, [id]: { pass, detail } }))
214
+ }
215
+
216
+ useEffect(() => {
217
+ const mountStamp = mountStampRef.current
218
+ const now = (): number => Date.now() - mountStamp
219
+ const traces = tracesRef.current!
220
+
221
+ // The production error channel tap (the U5 pattern): refusal reports ride consoleReporter →
222
+ // console.error. Installed for this screen's lifetime only; everything passes through.
223
+ const originalError = console.error
224
+ console.error = (...args: unknown[]) => {
225
+ for (const arg of args) {
226
+ const message = arg instanceof Error ? arg.message : typeof arg === 'string' ? arg : null
227
+ if (message !== null && /motion/i.test(message)) reportsRef.current.push(message)
228
+ }
229
+ originalError(...args)
230
+ }
231
+
232
+ const timers: ReturnType<typeof setTimeout>[] = []
233
+ const at = (ms: number, run: () => void): void => {
234
+ timers.push(setTimeout(run, ms))
235
+ }
236
+
237
+ // 1. SETTLE ORDERING — finalizes once ALL FOUR spring polls resolved. A null landing is
238
+ // the loud never-animates / never-settles class and names the box.
239
+ const maybeFinalizeOrdering = (): void => {
240
+ const landings = landingsRef.current
241
+ const keys: readonly SpringKey[] = ['loose', 'default', 'zero', 'tight']
242
+ if (!keys.every((key) => landings[key] !== undefined)) return
243
+ const start = startStampRef.current
244
+ const missing = keys.find((key) => landings[key] === null)
245
+ if (missing !== undefined) {
246
+ const trace = traces[missing]
247
+ finalize(
248
+ ROW_ORDER,
249
+ false,
250
+ `the ${missing} box never settled within the ${SPRING_SETTLE_DEADLINE_MS}ms deadline ` +
251
+ `(latest ${trace.latest.toFixed(1)}, ${trace.samples.length} onUpdate samples) — ` +
252
+ (trace.samples.length === 0
253
+ ? 'it never animated'
254
+ : 'it never reached a quiet settle at the target — the never-settles class (on ' +
255
+ 'the zero box, a literal-0 honoring makes the AND settle unsatisfiable: the ' +
256
+ '||= quirk is not in effect)'),
257
+ )
258
+ setStage('ordering row finalized (FAIL) — the tween and clean-run rows remain')
259
+ return
260
+ }
261
+ const ms = (key: SpringKey): number => landings[key]!.settleAt - start
262
+ const loose = ms('loose')
263
+ const dflt = ms('default')
264
+ const zero = ms('zero')
265
+ const tight = ms('tight')
266
+ const earlyGap = dflt - loose
267
+ const lateGap = tight - dflt
268
+ const zeroGap = Math.abs(zero - dflt)
269
+ const stamps = `loose +${loose.toFixed(0)}ms, default +${dflt.toFixed(0)}ms, zero +${zero.toFixed(0)}ms, tight +${tight.toFixed(0)}ms`
270
+ let bad: string | null = null
271
+ if (earlyGap <= LOOSE_EARLY_MARGIN_MS) {
272
+ bad =
273
+ `the loose thresholds did not settle early: default − loose = ${earlyGap.toFixed(0)}ms ` +
274
+ `(want > ${LOOSE_EARLY_MARGIN_MS}ms; ${stamps}) — the authored restDelta/restSpeed ` +
275
+ 'did not reach the solver (dropped or refused thresholds collapse every box onto the ' +
276
+ "pin's 2 / 0.5 defaults and this gap reads ~0)"
277
+ } else if (lateGap <= TIGHT_LATE_MARGIN_MS) {
278
+ bad =
279
+ `the tight thresholds did not settle late: tight − default = ${lateGap.toFixed(0)}ms ` +
280
+ `(want > ${TIGHT_LATE_MARGIN_MS}ms; ${stamps}) — the authored restDelta/restSpeed did ` +
281
+ 'not reach the solver'
282
+ } else if (zeroGap > ZERO_DEFAULT_BAND_MS) {
283
+ bad =
284
+ `explicit 0 did not read as absent: |zero − default| = ${zeroGap.toFixed(0)}ms ` +
285
+ `(want ≤ ${ZERO_DEFAULT_BAND_MS}ms, a couple frames; ${stamps}) — the pin's ||= quirk ` +
286
+ '(spring.ts:275-280) is not in effect'
287
+ }
288
+ finalize(
289
+ ROW_ORDER,
290
+ bad === null,
291
+ bad ??
292
+ `last-onUpdate settle stamps (post-trigger): ${stamps} — default − loose = ` +
293
+ `${earlyGap.toFixed(0)}ms (> ${LOOSE_EARLY_MARGIN_MS}: the loose thresholds reached ` +
294
+ `the solver), tight − default = ${lateGap.toFixed(0)}ms (> ${TIGHT_LATE_MARGIN_MS}: ` +
295
+ `the tight thresholds reached it), |zero − default| = ${zeroGap.toFixed(0)}ms (≤ ` +
296
+ `${ZERO_DEFAULT_BAND_MS}, a couple frames: explicit 0 rides the default selection — ` +
297
+ 'the ||= quirk verbatim)',
298
+ )
299
+ setStage('ordering row finalized — the tween and clean-run rows remain')
300
+ }
301
+
302
+ // 2. EXACT SNAP — finalizes when the loose box's poll resolves. The last pre-settle sample
303
+ // must sit far off the target (the AND settle fired mid-flight) and the committed
304
+ // post-settle value must be EXACTLY 100 (the snap-at-done, not the in-flight current).
305
+ const finalizeSnap = (landing: SettleLanding | null): void => {
306
+ const start = startStampRef.current
307
+ const post = traces.loose.samples.filter((s) => s.at >= start)
308
+ const snapIndex = post.findIndex((s) => s.value === TARGET)
309
+ let bad: string | null = null
310
+ if (landing === null) {
311
+ bad =
312
+ `the loose box never settled within the ${SPRING_SETTLE_DEADLINE_MS}ms deadline ` +
313
+ `(latest ${traces.loose.latest.toFixed(1)}) — the snap cannot be witnessed`
314
+ } else if (snapIndex === -1) {
315
+ bad =
316
+ `no post-trigger onUpdate sample reads exactly ${TARGET} (latest ` +
317
+ `${traces.loose.latest.toFixed(1)}) — the settle committed the current value, not the ` +
318
+ 'target (the pin commits `state.done ? target : current`)'
319
+ } else if (snapIndex === 0) {
320
+ bad =
321
+ `the loose box's FIRST post-trigger sample already reads ${TARGET} — the mid-flight ` +
322
+ 'settle was never witnessed (a teleport, not a spring flight)'
323
+ } else {
324
+ const preSettle = post[snapIndex - 1]!
325
+ if (preSettle.value >= LOOSE_PRE_SETTLE_BOUND) {
326
+ bad =
327
+ `the loose box's last pre-settle sample reads ${preSettle.value.toFixed(1)} at ` +
328
+ `+${(preSettle.at - start).toFixed(0)}ms (want < ${LOOSE_PRE_SETTLE_BOUND}) — the ` +
329
+ 'loose AND settle did not fire mid-flight (dropped thresholds settle it at the ' +
330
+ 'default ~517ms, where the pre-settle sample reads ~99.5)'
331
+ } else if (traces.loose.latest !== TARGET) {
332
+ bad =
333
+ `the loose box's committed post-settle value reads ${traces.loose.latest}, not ` +
334
+ `exactly ${TARGET} — no exact snap at rest`
335
+ }
336
+ }
337
+ finalize(
338
+ ROW_SNAP,
339
+ bad === null,
340
+ bad ??
341
+ `the loose AND settle fired mid-flight: the last pre-settle onUpdate reads ` +
342
+ `${post[snapIndex - 1]!.value.toFixed(1)} at +${(post[snapIndex - 1]!.at - start).toFixed(0)}ms ` +
343
+ `(< ${LOOSE_PRE_SETTLE_BOUND}; restDelta 80 admits |target − current| ≤ 80, ` +
344
+ 'restSpeed 2000 never binds), and the committed post-settle value is EXACTLY ' +
345
+ `${TARGET} — the pin's snap-at-done (\`state.done ? target : current\`), a ~` +
346
+ `${(TARGET - post[snapIndex - 1]!.value).toFixed(0)}px commit jump that only the ` +
347
+ `snap produces; settle stamp +${(landing!.settleAt - start).toFixed(0)}ms`,
348
+ )
349
+ setStage('snap row finalized')
350
+ }
351
+
352
+ // 3. TWEEN IGNORES THEM — finalizes when the tween box's poll resolves: the authored tight
353
+ // thresholds neither refused (the flight ran, no report — row 4 owns the tap), nor
354
+ // truncated (mid-flight samples strictly between, the settle lands in the 600ms class),
355
+ // nor mis-settled it.
356
+ const finalizeTween = (landing: SettleLanding | null): void => {
357
+ const start = startStampRef.current
358
+ const post = traces.tween.samples.filter((s) => s.at >= start)
359
+ const interior = post.filter(
360
+ (s) => s.value > 0 + FLIGHT_MARGIN && s.value < TARGET - FLIGHT_MARGIN,
361
+ )
362
+ let bad: string | null = null
363
+ if (landing === null) {
364
+ bad =
365
+ `the tween box never settled within the ${TWEEN_SETTLE_DEADLINE_MS}ms deadline ` +
366
+ `(latest ${traces.tween.latest.toFixed(1)}, ${post.length} samples) — the authored ` +
367
+ 'thresholds refused or mis-settled the tween (the pin ignores them outside the ' +
368
+ 'spring generator)'
369
+ } else if (interior.length === 0) {
370
+ bad =
371
+ `ZERO mid-flight samples strictly between 0 and ${TARGET} — the tween did not fly ` +
372
+ `its 600ms class (latest ${traces.tween.latest.toFixed(1)})`
373
+ } else {
374
+ const latency = landing.settleAt - start
375
+ if (latency < TWEEN_CLASS_FLOOR_MS || latency > TWEEN_CLASS_CEILING_MS) {
376
+ bad =
377
+ `the tween settled at +${latency.toFixed(0)}ms, outside its 600ms class band ` +
378
+ `(${TWEEN_CLASS_FLOOR_MS}–${TWEEN_CLASS_CEILING_MS}ms) — the authored thresholds ` +
379
+ 'truncated or re-classed the flight'
380
+ } else if (Math.abs(traces.tween.latest - TARGET) > EPS) {
381
+ bad = `the tween committed ${traces.tween.latest.toFixed(1)}, want ${TARGET}`
382
+ }
383
+ }
384
+ finalize(
385
+ ROW_TWEEN,
386
+ bad === null,
387
+ bad ??
388
+ `the 600ms tween flew its full class with restDelta/restSpeed 1e-5 authored: ` +
389
+ `${interior.length} mid-flight samples strictly between 0 and ${TARGET} ` +
390
+ `(${interior[0]!.value.toFixed(1)} → ${interior[interior.length - 1]!.value.toFixed(1)}), ` +
391
+ `settled at +${(landing!.settleAt - start).toFixed(0)}ms post-trigger (the ` +
392
+ `${TWEEN_CLASS_FLOOR_MS}–${TWEEN_CLASS_CEILING_MS}ms band, 600ms nominal + ` +
393
+ `delivery), final ${traces.tween.latest.toFixed(1)} — the thresholds neither ` +
394
+ 'refused, nor truncated, nor mis-settled the tween (the pin parity: only the ' +
395
+ 'spring and inertia generators read them)',
396
+ )
397
+ setStage('tween row finalized')
398
+ }
399
+
400
+ // THE TRIGGER — one state flip flies all five boxes 0 → 100; each box's settle is awaited
401
+ // by its own fail-closed quiescence poll (never a fixed frame chain).
402
+ at(T_START, () => {
403
+ startStampRef.current = now()
404
+ const start = startStampRef.current
405
+ setOn(true)
406
+ setStage(
407
+ `trigger fired: 0 → ${TARGET} on the catalog 300/28 spring (loose / default / zero / ` +
408
+ 'tight) + the threshold-carrying 600ms tween',
409
+ )
410
+ const springKeys: readonly SpringKey[] = ['loose', 'default', 'zero', 'tight']
411
+ for (const key of springKeys) {
412
+ pollSettle(
413
+ traces[key],
414
+ start,
415
+ start + SPRING_SETTLE_DEADLINE_MS,
416
+ now,
417
+ trackRaf,
418
+ (landing) => {
419
+ landingsRef.current[key] = landing
420
+ if (key === 'loose') finalizeSnap(landing)
421
+ maybeFinalizeOrdering()
422
+ },
423
+ )
424
+ }
425
+ pollSettle(
426
+ traces.tween,
427
+ start,
428
+ start + TWEEN_SETTLE_DEADLINE_MS,
429
+ now,
430
+ trackRaf,
431
+ (landing) => {
432
+ landingsRef.current.tween = landing
433
+ finalizeTween(landing)
434
+ },
435
+ )
436
+ })
437
+
438
+ // 4. CLEAN RUN — the console tap saw zero refusal reports across the whole timeline.
439
+ at(T_CLEAN, () => {
440
+ const reports = reportsRef.current
441
+ finalize(
442
+ ROW_CLEAN,
443
+ reports.length === 0,
444
+ reports.length === 0
445
+ ? `0 refusal reports through the console tap across ${now().toFixed(0)}ms of ` +
446
+ 'the trigger, all four spring settles, and the threshold-carrying tween'
447
+ : `${reports.length} report(s): ${reports
448
+ .map((message) => `"${message.slice(0, 80)}…"`)
449
+ .join(' | ')}`,
450
+ )
451
+ })
452
+ at(T_DONE, () => setStage('done'))
453
+
454
+ return () => {
455
+ for (const timer of timers) clearTimeout(timer)
456
+ for (const raf of rafsRef.current) cancelAnimationFrame(raf)
457
+ console.error = originalError
458
+ }
459
+ // All captured values are refs or stable setters; the timeline runs once per mount.
460
+ // eslint-disable-next-line react-hooks/exhaustive-deps
461
+ }, [])
462
+
463
+ const done = ROWS.every((id) => rows[id] !== undefined)
464
+ const failing = ROWS.filter((id) => rows[id] !== undefined && !rows[id]!.pass)
465
+ const verdict = !done ? 'RUNNING' : failing.length === 0 ? 'PASS' : 'FAIL'
466
+ const passed = Object.keys(rows).length - failing.length
467
+
468
+ return (
469
+ <ScrollView style={styles.scroll} contentContainerStyle={styles.container}>
470
+ <Text style={styles.title}>U7c rest-threshold checks</Text>
471
+ <Text style={styles.subtitle}>
472
+ authored restDelta/restSpeed order the spring settle — the catalog useSpring.tsx element
473
+ form ({severity} severity)
474
+ </Text>
475
+ <View
476
+ style={[
477
+ styles.banner,
478
+ verdict === 'PASS' ? styles.pass : verdict === 'FAIL' ? styles.fail : styles.running,
479
+ ]}
480
+ >
481
+ <Text style={styles.bannerText}>{verdict}</Text>
482
+ </View>
483
+ <Text style={styles.tally}>
484
+ {done
485
+ ? `${passed} PASS / ${failing.length} FAIL of ${ROWS.length} rows`
486
+ : `${Object.keys(rows).length}/${ROWS.length} rows complete — RUNNING`}
487
+ </Text>
488
+ <Text style={styles.tallyDetail}>
489
+ {done
490
+ ? failing.length === 0
491
+ ? 'no failing rows'
492
+ : `FAILING: ${failing.join(' | ')}`
493
+ : `stage: ${stage}`}
494
+ </Text>
495
+ <Text style={styles.status}>
496
+ u7c stage: {stage} · loose={live.loose ?? '0.0'} default={live.default ?? '0.0'} zero=
497
+ {live.zero ?? '0.0'} tight={live.tight ?? '0.0'} tween={live.tween ?? '0.0'}
498
+ </Text>
499
+
500
+ {/* The subjects: five boxes on one trigger, 0 → 100. The four spring boxes share the
501
+ catalog's 300/28 spring and differ ONLY in the authored thresholds (loose 80/2000,
502
+ default none, zero 0/0, tight 1e-5/1e-5 — the catalog values verbatim); the fifth
503
+ flies an ordinary 600ms tween carrying the tight thresholds (the pin ignores them
504
+ outside the spring generator). onUpdate on each is the committed-values readback. */}
505
+ <View style={styles.stageRow}>
506
+ <MotionView
507
+ initial={false}
508
+ animate={{ x: on ? TARGET : 0 }}
509
+ transition={{ ...SPRING, restDelta: 80, restSpeed: 2000 }}
510
+ onUpdate={(latest) => {
511
+ const value = readX(latest)
512
+ if (value !== null) record('loose', value)
513
+ }}
514
+ style={styles.looseBox}
515
+ accessibilityLabel="u7c-loose-subject"
516
+ />
517
+ <MotionView
518
+ initial={false}
519
+ animate={{ x: on ? TARGET : 0 }}
520
+ transition={SPRING}
521
+ onUpdate={(latest) => {
522
+ const value = readX(latest)
523
+ if (value !== null) record('default', value)
524
+ }}
525
+ style={styles.defaultBox}
526
+ accessibilityLabel="u7c-default-subject"
527
+ />
528
+ <MotionView
529
+ initial={false}
530
+ animate={{ x: on ? TARGET : 0 }}
531
+ transition={{ ...SPRING, restDelta: 0, restSpeed: 0 }}
532
+ onUpdate={(latest) => {
533
+ const value = readX(latest)
534
+ if (value !== null) record('zero', value)
535
+ }}
536
+ style={styles.zeroBox}
537
+ accessibilityLabel="u7c-zero-subject"
538
+ />
539
+ <MotionView
540
+ initial={false}
541
+ animate={{ x: on ? TARGET : 0 }}
542
+ transition={{ ...SPRING, restDelta: 0.00001, restSpeed: 0.00001 }}
543
+ onUpdate={(latest) => {
544
+ const value = readX(latest)
545
+ if (value !== null) record('tight', value)
546
+ }}
547
+ style={styles.tightBox}
548
+ accessibilityLabel="u7c-tight-subject"
549
+ />
550
+ <MotionView
551
+ initial={false}
552
+ animate={{ x: on ? TARGET : 0 }}
553
+ transition={{ type: 'tween', duration: 0.6, restDelta: 0.00001, restSpeed: 0.00001 }}
554
+ onUpdate={(latest) => {
555
+ const value = readX(latest)
556
+ if (value !== null) record('tween', value)
557
+ }}
558
+ style={styles.tweenBox}
559
+ accessibilityLabel="u7c-tween-subject"
560
+ />
561
+ </View>
562
+
563
+ {ROWS.map((id) => {
564
+ const row = rows[id]
565
+ return (
566
+ <View key={id} style={styles.row}>
567
+ <Text
568
+ style={[
569
+ styles.verdict,
570
+ row === undefined ? styles.pending : row.pass ? styles.ok : styles.bad,
571
+ ]}
572
+ >
573
+ {row === undefined ? '…' : row.pass ? 'PASS' : 'FAIL'}
574
+ </Text>
575
+ <View style={styles.rowBody}>
576
+ <Text style={styles.rowId}>{id}</Text>
577
+ <Text style={styles.rowDetail} numberOfLines={4}>
578
+ {row === undefined ? 'pending' : row.detail}
579
+ </Text>
580
+ </View>
581
+ </View>
582
+ )
583
+ })}
584
+ </ScrollView>
585
+ )
586
+ }
587
+
588
+ const styles = StyleSheet.create({
589
+ scroll: { flex: 1, backgroundColor: '#0b0b12' },
590
+ container: { paddingVertical: 72, paddingHorizontal: 8 },
591
+ title: { color: '#fff', fontSize: 22, fontWeight: '700', textAlign: 'center' },
592
+ subtitle: { color: '#8a8aa0', fontSize: 12, marginTop: 6, textAlign: 'center' },
593
+ banner: {
594
+ alignSelf: 'center',
595
+ paddingHorizontal: 36,
596
+ paddingVertical: 10,
597
+ borderRadius: 10,
598
+ marginTop: 18,
599
+ marginBottom: 10,
600
+ },
601
+ pass: { backgroundColor: '#1d7a3e' },
602
+ fail: { backgroundColor: '#a32633' },
603
+ running: { backgroundColor: '#7a641d' },
604
+ bannerText: { color: '#fff', fontSize: 22, fontWeight: '800', letterSpacing: 2 },
605
+ tally: { color: '#e6e6f0', fontSize: 14, fontWeight: '700', textAlign: 'center' },
606
+ tallyDetail: { color: '#f87171', fontSize: 11, marginTop: 3, textAlign: 'center' },
607
+ status: {
608
+ color: '#c9c9d8',
609
+ fontSize: 11,
610
+ marginTop: 8,
611
+ textAlign: 'center',
612
+ fontFamily: 'Menlo',
613
+ },
614
+ stageRow: {
615
+ flexDirection: 'row',
616
+ marginTop: 18,
617
+ alignItems: 'center',
618
+ justifyContent: 'space-evenly',
619
+ },
620
+ // The web oracle's palette (e2e/fixtures/examples.tsx U7c card).
621
+ looseBox: { width: 56, height: 56, backgroundColor: '#0ea5e9', borderRadius: 10 },
622
+ defaultBox: { width: 56, height: 56, backgroundColor: '#64748b', borderRadius: 10 },
623
+ zeroBox: { width: 56, height: 56, backgroundColor: '#f59e0b', borderRadius: 10 },
624
+ tightBox: { width: 56, height: 56, backgroundColor: '#7c3aed', borderRadius: 10 },
625
+ tweenBox: { width: 56, height: 56, backgroundColor: '#22c55e', borderRadius: 10 },
626
+ row: { flexDirection: 'row', marginTop: 10 },
627
+ verdict: { width: 52, fontSize: 12, fontWeight: '800', paddingTop: 1 },
628
+ ok: { color: '#4ade80' },
629
+ bad: { color: '#f87171' },
630
+ pending: { color: '#8a8aa0' },
631
+ rowBody: { flex: 1 },
632
+ rowId: { color: '#e6e6f0', fontSize: 13, fontWeight: '600' },
633
+ rowDetail: { color: '#6f6f85', fontSize: 11, marginTop: 2 },
634
+ })