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
+203
View File
@@ -0,0 +1,203 @@
import { markRaw, reactive, shallowReactive, shallowRef } from "vue";
import type { OriginView } from "../view/types";
import type {
MutableOriginOperation,
OriginContext,
OriginSceneNodeState,
} from "./types";
/** Mutable storage for one mounted scene node. */
export interface SceneNodeRecord {
key: string;
sequence: number;
view: OriginView;
/** Retained-history link: the entry a committed back returns to. */
previousNodeKey?: string;
state: OriginSceneNodeState;
/** Visual link: the operation edge positioning this node as its target. */
incomingOperationId?: number;
}
/** Fields supplied when a new operation edge is created. */
export type OperationInit = Omit<
MutableOriginOperation,
"id" | "progress" | "velocity" | "phase" | "intent" | "targetRect"
>;
/**
* Reactive storage for a scene's nodes and operation edges.
*
* Two independent structures share these records:
*
* - **History** links each node to its retained previous entry through
* `previousNodeKey`. It changes only when operations commit or cancel.
* - **Visual graph** links each target to the operation that positions it
* through `incomingOperationId`; each operation links back to its origin.
* Nodes without an incoming edge are listed in `roots`.
*
* Nodes are stored flat so Vue never re-parents a component when edges are
* added, spliced, or removed. This class owns structural primitives only;
* navigation semantics live in `transitions.ts`.
*/
export class SceneGraph {
readonly nodes = shallowReactive(new Map<string, SceneNodeRecord>());
readonly operations = shallowReactive(
new Map<number, MutableOriginOperation>(),
);
readonly roots = shallowRef<string[]>([]);
/**
* Connected resting edges temporarily removed by a back operation, keyed by
* that back operation's id. Cancelling the back operation restores the edge.
*/
readonly suspendedEdges = new Map<number, MutableOriginOperation>();
private nodeSequence = 0;
private operationSequence = 0;
constructor(initial: readonly OriginView[]) {
this.roots.value = initial.map((view) => this.createNode(view).key);
}
// Queries -----------------------------------------------------------------
node(key: string): SceneNodeRecord | undefined {
return this.nodes.get(key);
}
operation(id: number): MutableOriginOperation | undefined {
return this.operations.get(id);
}
/** The edge positioning `node` as its target, including resting edges. */
incomingOf(node: SceneNodeRecord): MutableOriginOperation | undefined {
return node.incomingOperationId
? this.operations.get(node.incomingOperationId)
: undefined;
}
/** The unfinished operation originated by a node, if any. */
liveOutgoingOf(nodeKey: string): MutableOriginOperation | undefined {
for (const operation of this.operations.values())
if (operation.originKey === nodeKey && operation.phase !== "finished")
return operation;
return undefined;
}
/**
* The operation whose source effects apply to a node. Unlike
* {@link liveOutgoingOf}, this includes a finished, connected resting edge.
*/
presentedOutgoingOf(nodeKey: string): MutableOriginOperation | undefined {
for (const operation of this.operations.values())
if (operation.originKey === nodeKey) return operation;
return undefined;
}
/** Retained entries preceding `node`, oldest first. */
historyOf(node: SceneNodeRecord): SceneNodeRecord[] {
const history: SceneNodeRecord[] = [];
const visited = new Set<string>([node.key]);
let previousKey = node.previousNodeKey;
while (previousKey && !visited.has(previousKey)) {
visited.add(previousKey);
const previous = this.nodes.get(previousKey);
if (!previous) break;
history.push(previous);
previousKey = previous.previousNodeKey;
}
return history.reverse();
}
contextFor(nodeKey: string): OriginContext {
const node = this.nodes.get(nodeKey);
if (!node)
throw new Error(`Origin scene node "${nodeKey}" no longer exists.`);
const history = this.historyOf(node).map((entry) => entry.view);
return {
nodeKey,
view: node.view,
canGoBack: Boolean(node.previousNodeKey),
previous: history.at(-1),
history,
};
}
// Mutations ---------------------------------------------------------------
createNode(view: OriginView, previousNodeKey?: string): SceneNodeRecord {
const sequence = ++this.nodeSequence;
const node: SceneNodeRecord = shallowReactive({
key: `${view.key ?? view.name ?? "view"}::${sequence}`,
sequence,
view: markRaw(view),
previousNodeKey,
state: "active",
incomingOperationId: undefined,
});
this.nodes.set(node.key, node);
return node;
}
createOperation(init: OperationInit): MutableOriginOperation {
const operation = reactive<MutableOriginOperation>({
id: ++this.operationSequence,
originKey: init.originKey,
targetKey: init.targetKey,
entryTargetKey: init.entryTargetKey,
choreography: markRaw(init.choreography),
placement: init.placement,
history: init.history,
progress: 0,
velocity: 0,
phase: "preparing",
intent: "undecided",
originRect: init.originRect,
});
this.operations.set(operation.id, operation);
return operation;
}
removeNode(nodeKey: string): void {
this.nodes.delete(nodeKey);
this.roots.value = this.roots.value.filter((key) => key !== nodeKey);
}
/**
* Remove a node, every node whose retained history passes through it, and
* every operation touching any of them. Earlier entries are untouched.
*/
removeBranch(nodeKey: string, visited = new Set<string>()): void {
if (visited.has(nodeKey)) return;
visited.add(nodeKey);
for (const node of [...this.nodes.values()])
if (node.previousNodeKey === nodeKey)
this.removeBranch(node.key, visited);
for (const operation of [...this.operations.values()])
if (
operation.originKey === nodeKey ||
operation.targetKey === nodeKey ||
operation.entryTargetKey === nodeKey
)
this.operations.delete(operation.id);
this.removeNode(nodeKey);
}
/**
* Give `target` the visual-graph position currently held by `from`: either
* as the target of `from`'s incoming edge, or as a root. Neither Vue VNode
* moves; only edge bookkeeping changes.
*/
transferPosition(from: SceneNodeRecord, target: SceneNodeRecord): void {
const parent = this.incomingOf(from);
if (parent) {
parent.targetKey = target.key;
target.incomingOperationId = parent.id;
} else {
this.roots.value = this.roots.value.map((key) =>
key === from.key ? target.key : key,
);
target.incomingOperationId = undefined;
}
}
}