Restructure core-v2 into layered modules

Rewrite the core-v2 internals without changing the public API or runtime
behavior. The four large files (types, scene, motion, gesture) become
layered modules with one responsibility each:

- shared/: geometry and environment helpers
- view/: originView recipes
- choreography/: effect types, presets, and composeEffects compositing
- actions/: forward/back/replace/above/under via one overload resolver
- scene/: SceneGraph storage, transitions (history rules), compositor
  (styles and interactivity), spring (pure stepper + rAF driver), and
  createOriginScene, which only wires them and owns async timing
- composables/: useOrigin and the node-scope injection key
- gesture/: builder, definition->policy normalization, start admission,
  DirectionalTracker, and a GestureRecognizer session state machine
  replacing fifteen loose closure variables
- components/: renderers and gesture hosts, plus their prop types

Verification (packages/core-v2/verify): differential fuzzing drives
main and this branch with identical random operation sequences and
pointer streams and requires identical scene state, styles, contexts,
callbacks, and promise results at every step. It caught every planted
mutation that applied. Benchmarks are at parity.

Also fix a pre-existing test that called the defineProps macro inside a
runtime defineComponent, and correct API.md's description of the flick
commit threshold to match the runtime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
This commit is contained in:
Claude
2026-10-16 01:15:12 +00:00
parent d8303107fb
commit 329271691c
48 changed files with 4134 additions and 2681 deletions
+183
View File
@@ -0,0 +1,183 @@
import type { OriginAction } from "../actions/types";
import type { UseOrigin } from "../composables/useOrigin";
import type { OriginContext, OriginOperationHandle } from "../scene/types";
import { measureRect, type OriginRect } from "../shared/geometry";
import { isHorizontal } from "./direction";
import type { RecognizerPolicy } from "./policy";
import { isIgnoredTarget, matchesStart, pointOf } from "./start";
import { DirectionalTracker } from "./tracker";
import type { OriginGesturePoint } from "./types";
/** The origin capabilities a recognizer needs. */
export type RecognizerOrigin = Pick<UseOrigin, "context" | "begin">;
/** State of one admitted pointer sequence, from pointer-down to release. */
interface PointerSession {
readonly pointerId: number;
readonly host: HTMLElement;
readonly bounds: OriginRect;
/** Origin context captured at pointer-down. */
readonly origin: OriginContext;
readonly start: OriginGesturePoint;
readonly startTime: number;
readonly tracker: DirectionalTracker;
/** Movement passed the recognition threshold and the pointer is captured. */
captured: boolean;
/** The scene operation, once the action resolved and `begin()` was called. */
operation: Promise<OriginOperationHandle | null> | null;
}
/**
* Turns one host's pointer events into a scene operation.
*
* Lifecycle of a session:
*
* 1. **down**: an eligible primary pointer opens a session.
* 2. **move**: once directed movement passes the threshold, the pointer is
* captured and the policy's action is resolved; a non-null action begins
* an operation, while a declined one closes the session. Every later move
* updates the operation's progress.
* 3. **up**: the session closes and the operation finishes, using the
* policy's completion predicate when present.
* 4. **cancel**: the session closes and the operation is cancelled.
*
* Action resolution and `begin()` are asynchronous. Each continuation checks
* that its session is still current, so a stale resolution never creates or
* updates an orphan operation.
*/
export class GestureRecognizer {
private session: PointerSession | null = null;
constructor(
private readonly policy: RecognizerPolicy,
private readonly origin: RecognizerOrigin,
) {}
pointerDown(event: PointerEvent): void {
const host = event.currentTarget;
if (
!event.isPrimary ||
event.button !== 0 ||
!(host instanceof HTMLElement) ||
isIgnoredTarget(event.target)
)
return;
const bounds = measureRect(host);
const origin = this.origin.context.value;
if (!matchesStart(this.policy.start, event, host, origin, bounds)) return;
// The component containing this declaration is the operation's origin.
event.stopPropagation();
const horizontal = isHorizontal(this.policy.direction);
this.session = {
pointerId: event.pointerId,
host,
bounds,
origin,
start: pointOf(event, bounds),
startTime: event.timeStamp,
tracker: new DirectionalTracker(this.policy.direction, event, () =>
horizontal ? host.clientWidth : host.clientHeight,
),
captured: false,
operation: null,
};
}
async pointerMove(event: PointerEvent): Promise<void> {
const session = this.sessionFor(event);
if (!session) return;
if (!session.captured) {
if (!this.recognizes(session, event)) return;
session.captured = true;
session.host.setPointerCapture?.(session.pointerId);
event.preventDefault();
session.tracker.sample(event);
const action = await this.resolveAction();
// The pointer may have been released or cancelled meanwhile.
if (this.session !== session) return;
if (!action) {
this.session = null;
return;
}
session.operation = this.origin.begin(action).catch(() => null);
} else {
event.preventDefault();
session.tracker.sample(event);
}
// Progress is read after the await so the latest buffered sample wins.
const pending = session.operation;
const handle = pending ? await pending : null;
if (this.session === session && session.operation === pending)
handle?.update(session.tracker.progress, session.tracker.velocity);
}
async pointerUp(event: PointerEvent): Promise<void> {
const session = this.sessionFor(event);
if (!session) return;
this.session = null;
if (!session.captured) return;
const { tracker } = session;
tracker.sample(event, true);
const commit = this.policy.completion?.({
origin: session.origin,
direction: this.policy.direction,
progress: tracker.progress,
velocity: tracker.velocity,
distance: tracker.distance,
crossDistance: tracker.crossDistance,
duration: Math.max(0, event.timeStamp - session.startTime),
event,
host: session.host,
bounds: session.bounds,
start: session.start,
current: pointOf(event, session.bounds),
});
const { progress, velocity } = tracker;
const handle = session.operation ? await session.operation : null;
if (!handle) return;
handle.update(progress, velocity);
await handle.finish(commit === undefined ? undefined : { commit });
}
async pointerCancel(): Promise<void> {
const session = this.session;
this.session = null;
if (!session?.captured) return;
const handle = session.operation ? await session.operation : null;
await handle?.cancel();
}
private sessionFor(event: PointerEvent) {
const session = this.session;
return session && session.pointerId === event.pointerId ? session : null;
}
/** Directed movement passed the threshold and dominates the cross axis. */
private recognizes(session: PointerSession, event: PointerEvent) {
const { distance, crossDistance } = session.tracker.displacement(event);
return (
distance >= this.policy.threshold &&
distance >= crossDistance * this.policy.axisDominance
);
}
/**
* Resolve the policy's action against the origin's current context. A
* declined or failed action resolves to `null`.
*/
private resolveAction(): Promise<OriginAction | null | undefined> {
try {
return Promise.resolve(
this.policy.action(this.origin.context.value),
).catch(() => null);
} catch {
return Promise.resolve(null);
}
}
}