Consolidate core-v2 modules and move scene logic onto classes

- Merge the small modules into 12 files: shared, choreography,
  navigation, scene/{types,graph,scene}, gesture/{definition,recognizer}.
- SceneGraph now owns the begin/update/commit/cancel history rules as
  methods, with the step helpers as private methods.
- createOriginScene returns a markRaw'd Scene class; operation handles
  are an OperationHandle class instead of inline object literals.
- GestureRecognizer gains static create()/policyFor() factories, absorbing
  the separate policy module.
- Pure helpers used by classes (matchesStart, pointOf, stepSpring,
  composeNodeStyle, nodeEffectLayers, shouldCommit, ...) are exported
  consts so they can be tested directly.
- Add haptics tests showing where navigator.vibrate can be triggered.

Public exports, types, and behavior are unchanged; the differential
suites against main still pass.

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 02:49:56 +00:00
parent 329271691c
commit 511e58c198
35 changed files with 2177 additions and 1989 deletions
+473
View File
@@ -0,0 +1,473 @@
import type { CSSProperties } from "vue";
import type { OriginChoreography } from "../choreography";
import type { MaybeOriginAction, OriginNavigationIntent } from "../navigation";
import type { OriginContext } from "../scene/types";
import type { OriginRect } from "../shared";
/*
* What a gesture is: the public gesture types, component props, and the
* immutable builder that produces executable definitions. Running them is
* the recognizer's job.
*/
/** Direction in which pointer movement advances operation progress. */
export type OriginGestureDirection = "left" | "right" | "up" | "down";
/** Physical side of the gesture host used to admit pointer-down. */
export type OriginGestureEdge = "left" | "right" | "top" | "bottom";
/**
* CSS length accepted by an edge-based gesture start rule.
*
* Numbers are interpreted as CSS pixels. Strings may use normal CSS lengths,
* percentages, `calc()`, or `clamp()`, and are resolved against the gesture
* host when pointer-down occurs.
*/
export type OriginGestureDistance = number | string;
/** A pointer position measured in viewport and gesture-host coordinates. */
export interface OriginGesturePoint {
/** Viewport-relative horizontal position. */
readonly clientX: number;
/** Viewport-relative vertical position. */
readonly clientY: number;
/** Horizontal position relative to the gesture host's left edge. */
readonly localX: number;
/** Vertical position relative to the gesture host's top edge. */
readonly localY: number;
}
/** Context supplied to a custom `.from.when()` start predicate. */
export interface OriginGestureStartContext {
/** Native pointer-down event being considered. */
readonly event: PointerEvent;
/** Component instance from which the gesture would originate. */
readonly origin: OriginContext;
/** Element carrying the gesture's pointer handlers. */
readonly host: HTMLElement;
/** Gesture-host bounds captured at pointer-down. */
readonly bounds: OriginRect;
/** Pointer position at pointer-down. */
readonly point: OriginGesturePoint;
}
/**
* Synchronous predicate deciding whether a pointer-down may become a gesture.
*
* Directional recognition still occurs later, after movement passes the
* configured intent threshold.
*/
export type OriginGestureStartPredicate = (
context: OriginGestureStartContext,
) => boolean;
/** Start policy stored in a completed gesture definition. */
export type OriginGestureStart =
| {
/** Recognize pointer-down anywhere on the host. */
readonly kind: "anywhere";
}
| {
/** Recognize pointer-down within a CSS distance of one host side. */
readonly kind: "edge";
readonly edge: OriginGestureEdge;
readonly distance: OriginGestureDistance;
}
| {
/** Recognize pointer-down when application policy returns `true`. */
readonly kind: "when";
readonly predicate: OriginGestureStartPredicate;
};
/** Direction-recognition tuning accepted by `.to.left()` and its siblings. */
export interface OriginGestureDirectionOptions {
/**
* Minimum directed movement in CSS pixels before the gesture captures.
*
* @defaultValue `8`
*/
readonly threshold?: number;
/**
* Ratio by which directed movement must exceed cross-axis movement.
*
* @defaultValue `1.15`
*/
readonly axisDominance?: number;
}
/** Values available when a custom `.complete()` policy runs on pointer-up. */
export interface OriginGestureCompletionContext {
/** Component instance that originated the gesture. */
readonly origin: OriginContext;
/** Recognized movement direction. */
readonly direction: OriginGestureDirection;
/** Normalized directed distance, clamped to `0..1`. */
readonly progress: number;
/** Latest normalized progress units per second. */
readonly velocity: number;
/** Directed movement from pointer-down in CSS pixels. */
readonly distance: number;
/** Absolute cross-axis movement from pointer-down in CSS pixels. */
readonly crossDistance: number;
/** Elapsed time since pointer-down in milliseconds. */
readonly duration: number;
/** Native pointer-up event that ended the interaction. */
readonly event: PointerEvent;
/** Element carrying the gesture's pointer handlers. */
readonly host: HTMLElement;
/** Gesture-host bounds captured at pointer-down. */
readonly bounds: OriginRect;
/** Pointer position captured at pointer-down. */
readonly start: OriginGesturePoint;
/** Pointer position at release. */
readonly current: OriginGesturePoint;
}
/** Synchronous commit/cancel policy installed by `.complete()`. */
export type OriginGestureCompletionPredicate = (
context: OriginGestureCompletionContext,
) => boolean;
/**
* Values accepted from a gesture builder's `.navigate()` factory.
*
* A promise supports lazy view selection. Stale resolutions are discarded if
* the pointer has already ended or been cancelled.
*/
export type MaybeOriginNavigationIntent =
| OriginNavigationIntent
| null
| undefined
| Promise<OriginNavigationIntent | null | undefined>;
/** Factory that resolves navigation after directional recognition succeeds. */
export type OriginGestureNavigationFactory = (
context: OriginContext,
) => MaybeOriginNavigationIntent;
/**
* Immutable, executable result of a complete gesture builder chain.
*
* Pass this object to {@link useOriginGesture}. An omitted `.from` step is
* represented as an `anywhere` start rule.
*/
export interface OriginGestureDefinition {
/** Discriminator used by the compatibility recognizer overload. */
readonly kind: "origin-gesture-definition";
/** Pointer-down eligibility policy. */
readonly start: OriginGestureStart;
/** Direction in which movement advances operation progress. */
readonly direction: OriginGestureDirection;
/** Directional intent recognition tuning. */
readonly recognition: Readonly<OriginGestureDirectionOptions>;
/**
* Optional release decision. When omitted, choreography thresholds decide.
*/
readonly completion?: OriginGestureCompletionPredicate;
/** Node-local destination/history resolver. */
readonly navigation: OriginGestureNavigationFactory;
/** Visual routine paired with the navigation intent. */
readonly choreography: OriginChoreography;
}
/** Direction-selection stage shared by `gesture.to` and `.from.*().to`. */
export interface OriginGestureToBuilder {
/** Recognize leftward pointer movement. */
left(options?: OriginGestureDirectionOptions): OriginGestureDirectedBuilder;
/** Recognize rightward pointer movement. */
right(options?: OriginGestureDirectionOptions): OriginGestureDirectedBuilder;
/** Recognize upward pointer movement. */
up(options?: OriginGestureDirectionOptions): OriginGestureDirectedBuilder;
/** Recognize downward pointer movement. */
down(options?: OriginGestureDirectionOptions): OriginGestureDirectedBuilder;
}
/** Stage produced after a `.from` policy has been selected. */
export interface OriginGestureFromSelection {
/** Select the direction that advances this gesture. */
readonly to: OriginGestureToBuilder;
}
/** Optional pointer-down policy exposed at the start of a gesture chain. */
export interface OriginGestureFromBuilder {
/** Admit pointer-down within `distance` of the host's left edge. */
left(distance: OriginGestureDistance): OriginGestureFromSelection;
/** Admit pointer-down within `distance` of the host's right edge. */
right(distance: OriginGestureDistance): OriginGestureFromSelection;
/** Admit pointer-down within `distance` of the host's top edge. */
top(distance: OriginGestureDistance): OriginGestureFromSelection;
/** Admit pointer-down within `distance` of the host's bottom edge. */
bottom(distance: OriginGestureDistance): OriginGestureFromSelection;
/** Admit pointer-down anywhere on the gesture host. */
anywhere(): OriginGestureFromSelection;
/** Admit pointer-down when a synchronous application predicate succeeds. */
when(predicate: OriginGestureStartPredicate): OriginGestureFromSelection;
}
/** Builder stage after direction is known and completion remains optional. */
export interface OriginGestureDirectedBuilder {
/** Override the choreography's default release decision. */
complete(
predicate: OriginGestureCompletionPredicate,
): OriginGestureCompletedBuilder;
/** Select the target/history mutation while retaining default completion. */
navigate(
factory: OriginGestureNavigationFactory,
): OriginGestureNavigationBuilder;
}
/** Builder stage after a custom completion policy has been selected. */
export interface OriginGestureCompletedBuilder {
/** Select the target/history mutation performed on commit. */
navigate(
factory: OriginGestureNavigationFactory,
): OriginGestureNavigationBuilder;
}
/** Final builder stage waiting for visual choreography. */
export interface OriginGestureNavigationBuilder {
/** Attach visual choreography and produce an executable definition. */
animate(choreography: OriginChoreography): OriginGestureDefinition;
}
/** Root of the immutable fluent gesture-definition API. */
export interface OriginGestureBuilder {
/** Optionally constrain where pointer-down may begin. */
readonly from: OriginGestureFromBuilder;
/**
* Select movement direction with an implicit `from.anywhere()` start.
*/
readonly to: OriginGestureToBuilder;
}
/** Configuration consumed by {@link useOriginGesture}. */
export interface OriginGestureOptions {
/**
* Direction in which the pointer moves to advance the operation.
*
* The starting edge is the opposite side: `right` begins at the left edge,
* `left` at the right edge, `down` at the top, and `up` at the bottom.
*/
direction: OriginGestureDirection;
/**
* Restrict pointer-down to this many CSS pixels from the gesture host's
* starting edge. Omit it to recognize across the entire host.
*
* This is relative to the bound element, not necessarily the browser
* viewport. Use a positive number such as `24` or `36`.
*/
edge?: number;
/**
* Minimum directed movement in CSS pixels before the gesture captures.
*
* Movement must also dominate the cross-axis by a factor of `1.15`.
*
* @defaultValue `8`
*/
threshold?: number;
/**
* Resolve the scene action after directional recognition succeeds.
*
* Returning no action abandons recognition without modifying the scene.
*/
action(context: OriginContext): MaybeOriginAction;
}
/**
* DOM bindings returned by {@link useOriginGesture}.
*
* Spread or attach all four handlers to the same `HTMLElement`. Apply
* {@link style} as well so native scrolling is preserved on the cross-axis.
*/
export interface OriginGestureBinding {
/** Required size and `touch-action` styles for the gesture host. */
readonly style: Readonly<CSSProperties>;
/** Pointer-down handler that records a potentially eligible gesture. */
readonly onPointerdown: (event: PointerEvent) => void;
/** Pointer-move handler that recognizes and updates the operation. */
readonly onPointermove: (event: PointerEvent) => void;
/** Pointer-up handler that commits or cancels using progress and velocity. */
readonly onPointerup: (event: PointerEvent) => void;
/** Pointer-cancel handler that abandons any captured operation. */
readonly onPointercancel: () => void;
}
// Component props ----------------------------------------------------------
/** Props shared by both `OriginGesture` declaration styles. */
export interface OriginGestureBaseProps {
/**
* Native HTML tag rendered as the gesture host.
*
* @defaultValue `"div"`
*/
as?: string;
}
/** Builder-definition props accepted by the `OriginGesture` component. */
export interface OriginGestureDefinitionProps extends OriginGestureBaseProps {
/** Immutable definition produced by the {@link gesture} builder. */
gesture: OriginGestureDefinition;
/** Builder definitions already contain direction. */
direction?: never;
/** Builder definitions already contain their start policy. */
edge?: never;
/** Builder definitions already contain recognition tuning. */
threshold?: never;
/** Builder definitions already contain their navigation factory. */
action?: never;
}
/** Legacy option props accepted by the `OriginGesture` component. */
export interface OriginGestureLegacyProps extends OriginGestureBaseProps {
/** Legacy component declarations do not provide a builder definition. */
gesture?: never;
/** Direction in which pointer movement advances operation progress. */
direction: OriginGestureDirection;
/**
* Eligible pointer-down width in CSS pixels from the host's starting edge.
* Omit it to allow the full component surface.
*/
edge?: number;
/**
* Directed movement required before capture.
*
* @defaultValue `8`
*/
threshold?: number;
/** Node-local action factory invoked only after recognition succeeds. */
action(context: OriginContext): MaybeOriginAction;
}
/**
* Public props accepted by the `OriginGesture` convenience component.
*
* Prefer the builder-definition form. The legacy direction/action form remains
* available for compatibility.
*/
export type OriginGestureProps =
OriginGestureDefinitionProps | OriginGestureLegacyProps;
/** Public props accepted by the multi-definition gesture surface component. */
export interface OriginGestureSurfaceProps {
/**
* Native HTML tag rendered as the shared gesture host.
*
* @defaultValue `"div"`
*/
as?: string;
/**
* Complete immutable definitions installed on the shared host.
*
* The surface adds no start, direction, completion, navigation, or animation
* policy. Definitions should be created by the owning page component.
*/
gestures: readonly OriginGestureDefinition[];
}
// Builder ------------------------------------------------------------------
/*
* Immutable fluent builder. Each stage is a frozen object closing over the
* choices made so far; the final `.animate()` produces a frozen definition.
*
* gesture[.from.<start>].to.<direction>()[.complete()].navigate().animate()
*/
function navigationStage(
start: OriginGestureStart,
direction: OriginGestureDirection,
recognition: Readonly<OriginGestureDirectionOptions>,
completion: OriginGestureCompletionPredicate | undefined,
) {
return (
navigation: OriginGestureNavigationFactory,
): OriginGestureNavigationBuilder =>
Object.freeze({
animate: (choreography: OriginChoreography): OriginGestureDefinition =>
Object.freeze({
kind: "origin-gesture-definition",
start,
direction,
recognition,
completion,
navigation,
choreography,
}),
});
}
function directedStage(
start: OriginGestureStart,
direction: OriginGestureDirection,
options: OriginGestureDirectionOptions = {},
): OriginGestureDirectedBuilder {
const recognition = Object.freeze({ ...options });
return Object.freeze({
complete: (completion: OriginGestureCompletionPredicate) =>
Object.freeze({
navigate: navigationStage(start, direction, recognition, completion),
}),
navigate: navigationStage(start, direction, recognition, undefined),
});
}
function toStage(start: OriginGestureStart): OriginGestureToBuilder {
const direct =
(direction: OriginGestureDirection) =>
(options?: OriginGestureDirectionOptions) =>
directedStage(start, direction, options);
return Object.freeze({
left: direct("left"),
right: direct("right"),
up: direct("up"),
down: direct("down"),
});
}
function fromStage(start: OriginGestureStart): OriginGestureFromSelection {
return Object.freeze({ to: toStage(Object.freeze(start)) });
}
const fromEdge =
(edge: OriginGestureEdge) => (distance: OriginGestureDistance) =>
fromStage({ kind: "edge", edge, distance });
const fromBuilder: OriginGestureFromBuilder = Object.freeze({
left: fromEdge("left"),
right: fromEdge("right"),
top: fromEdge("top"),
bottom: fromEdge("bottom"),
anywhere: () => fromStage({ kind: "anywhere" }),
when: (predicate: OriginGestureStartPredicate) =>
fromStage({ kind: "when", predicate }),
});
/**
* Root of the immutable gesture builder.
*
* `.from` is optional. Starting at `.to` admits pointer-down anywhere on the
* bound host, exactly like `.from.anywhere().to`.
*
* @example Anywhere-to-right back gesture
* ```ts
* const swipeBack = gesture
* .to.right()
* .navigate((context) => context.canGoBack ? back() : null)
* .animate(slideRight);
* ```
*
* @example Predicate-gated gesture with custom completion
* ```ts
* const openPanel = gesture
* .from.when(({ point, bounds }) => point.localX <= bounds.width * 0.08)
* .to.down()
* .complete(({ progress, velocity }) => progress > 0.5 || velocity > 1)
* .navigate(() => above(originView(PanelView)))
* .animate(dropPanel);
* ```
*/
export const gesture: OriginGestureBuilder = Object.freeze({
from: fromBuilder,
to: toStage(Object.freeze({ kind: "anywhere" })),
});