@weasel-js/labkit 0.1.0 → 0.7.1

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.
@@ -1,7 +1,7 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
  import * as react from 'react';
3
- import { ReactNode, CSSProperties, ButtonHTMLAttributes, ReactElement, PointerEvent as PointerEvent$1, KeyboardEvent as KeyboardEvent$1, RefCallback } from 'react';
4
- import { b as NodeId, O as Op, V as View, B as Bounds, D as DrawCommand } from '../_dts/fitViewToBounds-rWrBI4a6.js';
3
+ import { ReactNode, CSSProperties, ButtonHTMLAttributes, ReactElement, MutableRefObject, PointerEvent as PointerEvent$1, KeyboardEvent, RefCallback } from 'react';
4
+ import { O as Op, V as View, D as DrawCommand, b as NodeId, c as Path, S as Scene, H as History, d as PoseComposition, B as Bounds } from '../_dts/DrawCommand-CEvS5AdV.js';
5
5
  import { TextFieldProps, ValidationResult, CheckboxProps as CheckboxProps$1, SwitchProps as SwitchProps$1, TabProps as TabProps$1, TabListProps as TabListProps$1, TabPanelProps as TabPanelProps$1, TabsProps as TabsProps$1, RadioProps as RadioProps$1, RadioGroupProps as RadioGroupProps$1, NumberFieldProps as NumberFieldProps$1, SelectProps as SelectProps$1, ListBoxItemProps, ComboBoxProps as ComboBoxProps$1, SliderProps as SliderProps$1, ModalOverlayProps, DialogProps as DialogProps$1 } from 'react-aria-components';
6
6
 
7
7
  /**
@@ -1031,6 +1031,23 @@ type ToggleBarProps<V extends string | number = string> = (CommonProps & {
1031
1031
  mode: 'multiple';
1032
1032
  items: readonly ToggleBarItem<V>[];
1033
1033
  value: readonly V[];
1034
+ /**
1035
+ * Values that are neither on nor off — the sources this bar
1036
+ * aggregates disagree (a text range that is bold in part of it, a
1037
+ * multi-selection whose nodes differ). Rendered `aria-pressed="mixed"`,
1038
+ * the ARIA tri-state a toggle button actually has, rather than
1039
+ * `SelectionPanel`'s reduced-opacity-plus-`title` workaround for
1040
+ * `Switch`, which has no indeterminate state to render.
1041
+ *
1042
+ * `value` wins where the two lists overlap, so a caller that can't
1043
+ * cheaply keep them disjoint doesn't get an ambiguous segment.
1044
+ *
1045
+ * Clicking a mixed segment turns it fully **on**, matching the
1046
+ * everywhere-else convention (and `toggleFlagInRange`'s rule for a
1047
+ * partially-styled text range) that a mixed toggle resolves toward
1048
+ * the affirmative rather than clearing.
1049
+ */
1050
+ mixedValues?: readonly V[];
1034
1051
  onChange: (next: V[]) => void;
1035
1052
  });
1036
1053
  declare function ToggleBar<V extends string | number = string>(props: ToggleBarProps<V>): ReactElement;
@@ -1107,422 +1124,189 @@ interface PowerlineProps {
1107
1124
  }
1108
1125
  declare function Powerline({ segments, startCap, size, variant, depth, gap, className, ...rest }: PowerlineProps): react_jsx_runtime.JSX.Element;
1109
1126
 
1110
- /** API returned by {@link useSelection}. */
1111
- interface SelectionApi {
1112
- /** Current selection. Re-renders trigger when this reference changes. */
1113
- current: readonly NodeId[];
1114
- /** Imperative read for use inside event callbacks (avoids stale closures). */
1115
- get(): NodeId[];
1116
- /** Replace selection. */
1117
- set(ids: NodeId[]): void;
1118
- /** Add id (multi-mode appends; single-mode replaces). */
1119
- add(id: NodeId): void;
1120
- /** Remove id from selection. */
1121
- remove(id: NodeId): void;
1122
- /** Toggle id in/out of selection. */
1123
- toggle(id: NodeId): void;
1124
- /** Clear selection. */
1125
- clear(): void;
1126
- /** True if id is selected. */
1127
- contains(id: NodeId): boolean;
1128
- /**
1129
- * Apply a click to the selection per the configured mode/extend key.
1130
- * - `single`: replaces selection with `[id]`, regardless of modifiers.
1131
- * - `multi`: with the extend key held, toggles `id` in/out of the selection;
1132
- * otherwise replaces with `[id]`.
1133
- */
1134
- applyClick(id: NodeId, modifiers: {
1135
- shift: boolean;
1136
- meta: boolean;
1137
- ctrl: boolean;
1138
- }): void;
1139
- /** Pre-built methods for spreading into an adapter that needs them. */
1140
- adapterMethods: {
1141
- getSelection: () => NodeId[];
1142
- setSelection: (ids: NodeId[]) => void;
1143
- };
1144
- }
1145
-
1146
- type HandleKind = 'corner' | 'rotation' | 'anchor';
1147
- type HitShape = {
1148
- kind: 'rect';
1127
+ type ContainerBounds = {
1149
1128
  x: number;
1150
1129
  y: number;
1151
1130
  width: number;
1152
1131
  height: number;
1153
- rotation?: number;
1154
- } | {
1155
- kind: 'circle';
1156
- cx: number;
1157
- cy: number;
1158
- r: number;
1159
- } | {
1160
- kind: 'path';
1161
- d: Path2D;
1162
1132
  };
1163
- interface DebugSink {
1164
- recordHitbox(id: string, kind: 'body' | 'handle' | 'rotation' | 'anchor', shape: HitShape): void;
1165
- recordHandle(id: string, position: {
1133
+ interface LayoutChild<TPose> {
1134
+ id: string;
1135
+ pose: TPose;
1136
+ }
1137
+ interface DropTarget<TPose> {
1138
+ /** Where the dragged child lands if this target is picked. */
1139
+ pose: TPose;
1140
+ /** Reference point for distance metrics (snap algorithms). */
1141
+ origin: {
1166
1142
  x: number;
1167
1143
  y: number;
1168
- }, kind: HandleKind): void;
1169
- recordBounds(id: string, bounds: {
1144
+ };
1145
+ /** Optional axis-aligned region (world units) used by region-aware snaps
1146
+ * (e.g. `containedThenNearest`). When present, a pointer inside this rect
1147
+ * is treated as a containment hit on this target. Strategies that emit
1148
+ * region-shaped targets (gutters, drop-zones) should populate this.
1149
+ * Strategies whose targets are point-like (free-form, snap-point) can omit
1150
+ * it and rely on `origin`-distance snaps. */
1151
+ hitBounds?: {
1170
1152
  x: number;
1171
1153
  y: number;
1172
1154
  width: number;
1173
1155
  height: number;
1174
- }): void;
1175
- recordOrigin(id: string, point: {
1156
+ };
1157
+ /** Strategy-private metadata (e.g. cell coords for tile-grid). */
1158
+ meta?: unknown;
1159
+ }
1160
+ interface LayoutSnap<TPose> {
1161
+ pickTarget(targets: DropTarget<TPose>[], pointer: {
1176
1162
  x: number;
1177
1163
  y: number;
1178
- }): void;
1179
- recordSnapCandidate(point: {
1164
+ }): DropTarget<TPose> | null;
1165
+ }
1166
+ interface LayoutContainer {
1167
+ id: string;
1168
+ bounds: ContainerBounds;
1169
+ }
1170
+ interface LayoutDragged<TPose> {
1171
+ id: string;
1172
+ /** The pose the dragged child currently has (pre-drop). */
1173
+ originPose: TPose;
1174
+ /** The pose the gesture proposes (pointer-driven, pre-snap). */
1175
+ pose: TPose;
1176
+ sourceContainerId: string | null;
1177
+ }
1178
+ interface LayoutStrategy<TPose> {
1179
+ childPoses(container: LayoutContainer, children: ReadonlyArray<LayoutChild<TPose>>): Map<string, TPose>;
1180
+ getDropTargets(container: LayoutContainer, children: ReadonlyArray<LayoutChild<TPose>>, dragged: LayoutDragged<TPose>): DropTarget<TPose>[];
1181
+ reflowPoses(container: LayoutContainer, children: ReadonlyArray<LayoutChild<TPose>>, dragged: LayoutDragged<TPose>, target: DropTarget<TPose> | null): Map<string, TPose>;
1182
+ commitDrop(container: LayoutContainer, children: ReadonlyArray<LayoutChild<TPose>>, dragged: LayoutDragged<TPose>, target: DropTarget<TPose> | null): Op[];
1183
+ snap: LayoutSnap<TPose>;
1184
+ /** Optional: predicate for whether a world-space point is inside this
1185
+ * container. When absent, callers fall back to an axis-aligned bounding-box
1186
+ * test on the container's pose. Strategies whose containers aren't
1187
+ * rectangular (circles, irregular zones) implement this to override the
1188
+ * AABB default. */
1189
+ contains?(containerPose: TPose, point: {
1180
1190
  x: number;
1181
1191
  y: number;
1182
- }, accepted: boolean): void;
1183
- recordLayer(id: string, label: string, space: 'world' | 'screen', index: number): void;
1184
- /** Clears every non-snap array. Called at the start of each Canvas render. */
1185
- beginFrame(): void;
1186
- /** Clears the snap array. Called at gesture end. */
1187
- clearSnap(): void;
1192
+ }): boolean;
1188
1193
  }
1189
1194
 
1190
1195
  /**
1191
- * Declarative gesture taxonomy. Single source of truth for:
1192
- * - which gestures hit-test (have a `.target` slot in the route string)
1193
- * - which gestures carry an argument and what values are legal
1194
- * - the default arg value (used when none is specified in a route)
1196
+ * Opaque clipboard payload. `items` is `unknown[]` so each app's clipboard
1197
+ * adapter stores whatever shape it wants; the kit never inspects entries.
1195
1198
  *
1196
- * Reflection, matcher, and inspector UI all read this table. Adding a new
1197
- * gesture name in one place updates every consumer.
1199
+ * The adapter is responsible for both producing snapshots
1200
+ * (`snapshotSelection`) and consuming them (`commitPaste`). Type safety lives
1201
+ * at that boundary, not in the kit.
1198
1202
  */
1199
- type GestureName = 'click' | 'pointerDown' | 'dblTap' | 'drag' | 'wheel' | 'keyDown' | 'keyUp' | 'keyHeld' | 'contextMenu' | 'multiTouchTap';
1200
-
1201
- /** All valid keys for a modifier sub-table in a route entry. Canonical
1202
- * order: mod → shift → alt (matches formatShortcut). */
1203
- type ModifierCombo = 'default' | 'mod' | 'shift' | 'alt' | 'mod+shift' | 'mod+alt' | 'shift+alt' | 'mod+shift+alt';
1204
-
1205
- /** Phase of a gesture lifecycle. `initial` means the tool is idle
1206
- * (scratch null); `engaged` means a gesture is in progress (scratch
1207
- * populated). The route-grammar's `[phase]` slot draws from this set. */
1208
- type RoutePhase$1 = 'initial' | 'engaged';
1209
-
1203
+ interface ClipboardSnapshot {
1204
+ items: unknown[];
1205
+ }
1210
1206
  /**
1211
- * Route-string grammar v3:
1212
- *
1213
- * route = phaseSlot WS gesture WS argSlot? WS targetSlot? WS modSlot?
1214
- * phaseSlot = '[' phaseList ']'
1215
- * phaseList = phaseAtom (WS ',' WS phaseAtom)*
1216
- * phaseAtom = (channel ':')? phaseValue -- bare phaseValue ≡ '&:phaseValue'
1217
- * channel = '&' | '*' | toolId -- '&' = the binding's own tool
1218
- * phaseValue = 'initial' | 'engaged' | '*'
1219
- * argSlot = '(' argValue ')' -- whitespace inside parens is significant
1220
- * targetSlot = '=>' WS targetValue -- omitted slot defaults to '*' for hasTarget
1221
- * modSlot = modAtom (WS modAtom)*
1222
- * modAtom = sigil modName
1223
- * sigil = '+' | '?' -- ! @ # $ % ^ & * reserved as id-prefix
1224
- * modName = 'mod' | 'shift' | 'alt' | 'ctrl' | 'meta'
1225
- *
1226
- * Shorthand: a bare phaseValue (no `:`) implies channel `&` ("this tool's
1227
- * own phase"). `[engaged]` ≡ `[&:engaged]`; `[*]` ≡ `[&:*]`. The truly-loose
1228
- * form (any channel, any phase) is `[*:*]`.
1207
+ * SnapTarget — where a dragged node would re-parent to if released.
1229
1208
  *
1230
- * Examples:
1231
- * [initial] click => empty +shift -- self idle
1232
- * [engaged] wheel -- self mid-gesture
1233
- * [rect:engaged] wheel -- when rect tool is mid-gesture
1234
- * [*:engaged] keyDown(Delete) -- when any tool is mid-gesture
1235
- * [initial,engaged] contextMenu => empty -- either self phase
1236
- * [*] click => empty -- self, any phase
1209
+ * `slotPose` is the pose (in world coordinates) the node should snap to
1210
+ * within the target. `metadata` is an opaque pass-through for app-specific
1211
+ * snap details (slot index, visual hint, etc.).
1237
1212
  */
1238
-
1239
- /** Channel reference for a phase atom. `'&'` = the binding's own tool;
1240
- * `'*'` = any tool; otherwise a registered tool id. */
1241
- type ChannelRef = '&' | '*' | string;
1242
- /** One element of a phase list: a (channel, phase) pair. The default
1243
- * channel (omitted in the shorthand) is `'&'`. `phase: '*'` means
1244
- * "any phase of the given channel". */
1245
- interface PhaseAtom {
1246
- channel: ChannelRef;
1247
- phase: RoutePhase$1 | '*';
1213
+ interface SnapTarget<TPose = unknown> {
1214
+ parentId: string;
1215
+ slotPose: TPose;
1216
+ metadata?: unknown;
1248
1217
  }
1249
-
1250
1218
  /**
1251
- * GestureSpec describes the form of a user input event that can fire an action.
1252
- *
1253
- * Used by `Action.defaultBinding` (the action's preferred gesture) and by
1254
- * `GestureBinding.spec` (a tool's binding table entry). The dispatcher matches
1255
- * incoming input events against registered specs to determine which action to
1256
- * invoke.
1257
- *
1258
- * See `docs/superpowers/specs/2026-05-16-registry-unification-design.md` § "Types".
1219
+ * Narrow adapter for `useMove`. Includes optional snap-target
1220
+ * lookup; apps without container-snapping leave it out.
1259
1221
  */
1260
- /** Optional modifier-key requirement for a gesture spec.
1261
- *
1262
- * Matching semantics (strict): an omitted modifier field means the
1263
- * modifier MUST NOT be held — i.e., a bare `{ kind: 'key', key: 'Escape' }`
1264
- * matches only unmodified Escape, NOT Cmd+Escape. A `true` means the
1265
- * modifier MUST be held; `false` is the same as omitted (must be absent).
1266
- * This mirrors today's `KeyBinding` matcher and keeps conflict detection
1267
- * coherent.
1222
+ interface MoveAdapter<TNode extends {
1223
+ id: string;
1224
+ }, TPose> {
1225
+ getNode(id: string): TNode | undefined;
1226
+ /** Enumerate all nodes. `<Canvas>` derives a default rect-pose `pickEvery`
1227
+ * and the scene-iteration loop from this. */
1228
+ getNodes(): TNode[];
1229
+ getPose(id: string): TPose;
1230
+ /** Optional. Required only by hierarchy-aware paths: layout-pass drop
1231
+ * targeting (`getLayout` present), nested-hit collapse
1232
+ * (`pickTopMostHit`), and group-pose composition. Flat scenes may omit. */
1233
+ getParent?(id: string): string | null;
1234
+ setPose(id: string, pose: TPose): void;
1235
+ /** Optional. Used only by reparent ops (e.g. drag-into-container drops via
1236
+ * layout strategies). Flat scenes that never reparent may omit. */
1237
+ setParent?(id: string, parentId: string | null): void;
1238
+ /** Optional: see SceneAdapter.applyOps. */
1239
+ applyOps?(ops: Op[], label: string): void;
1240
+ findSnapTarget?(draggedId: string, worldX: number, worldY: number): SnapTarget<TPose> | null;
1241
+ /** Optional: direct children of `id`. When present (alongside the
1242
+ * `cascadeWorldPose` option on `useMove`), dragging a node
1243
+ * auto-cascades its descendants in the live overlay so structurally-
1244
+ * grouped children visually follow the parent during the drag. No
1245
+ * additional ops are generated — children's local poses don't change
1246
+ * when the parent's local pose moves. */
1247
+ getChildren?(id: string): string[];
1248
+ /** Optional: layout strategy attached to a container, or null if the
1249
+ * container uses absolute positioning (default behavior). When present,
1250
+ * `useMove` uses the strategy to compute drop targets, sibling reflow,
1251
+ * and the commit op batch when a drag ends over the container. */
1252
+ getLayout?(containerId: string): LayoutStrategy<TPose> | null;
1253
+ }
1254
+ /**
1255
+ * Narrow adapter for `useInsert` and `useClipboardOps`. The kit knows
1256
+ * nothing about what tool is active or what shape to construct; it asks the
1257
+ * adapter to produce node(s) given gesture or paste inputs.
1268
1258
  *
1269
- * `mod` is a platform-aware shorthand: matches `metaKey` on mac, `ctrlKey`
1270
- * elsewhere (mirrors `KeyBinding.mod`).
1259
+ * Drag-rectangle path: `commitInsert(bounds)` returns one new node or null.
1260
+ * Clipboard paste path: `commitPaste(clipboard, offset)` returns the array of
1261
+ * newly-materialized nodes (in order). Both empty array and array of
1262
+ * length N are valid; the kit wraps each entry in an `InsertOp`.
1271
1263
  *
1272
- * `shift` additionally accepts `'optional'` meaning "shifted or unshifted
1273
- * both acceptable" the explicit opt-in for loose matching, used by
1274
- * actions like nudge whose step size depends on shift but whose firing
1275
- * does not. To widen other modifiers similarly, extend their type when
1276
- * a real consumer needs it.
1264
+ * `snapshotSelection(ids)` builds the payload that paste later consumes.
1265
+ * `getPasteOffset` is optional; the kit defaults to a fixed grid-cell offset
1266
+ * supplied by the consumer (passed to `useClipboardOps` options if needed; see
1267
+ * the hook for resolution order).
1277
1268
  */
1278
- type ModSpec = Partial<{
1279
- alt: boolean | 'optional';
1280
- ctrl: boolean | 'optional';
1281
- meta: boolean | 'optional';
1282
- mod: boolean | 'optional';
1283
- shift: boolean | 'optional';
1284
- }>;
1285
- /** Target selector for click and drag gesture specs. String forms are sugar
1286
- * for the kit-owned object-kind registry (TODO.md Tier 1 follow-up); until
1287
- * that ships, consumers can pass `{ kindOf: predicate }` to classify hits
1288
- * themselves. */
1289
- type TargetSpec = 'empty' | 'selected-body' | 'unselected-body' | `kind:${string}` | `kind:${string}:selected` | `affordance:${string}` | {
1290
- /** Predicate. `hit` is the raw target (affordance for drag,
1291
- * `e.target` otherwise); `bodyTarget` is the optional body-class
1292
- * string ('empty' | 'selected-body' | 'unselected-body') when
1293
- * `classifyTarget` is wired. Predicates that only need one of the
1294
- * two can ignore the other. */
1295
- kindOf: (hit: unknown, bodyTarget?: string) => boolean;
1296
- };
1297
- /** Phase qualifier on a gesture spec. Restricts when the spec matches based
1298
- * on per-tool gesture-lifecycle state.
1299
- *
1300
- * Shorthand forms (most common case — gate on the binding's own tool):
1301
- * `'engaged'` → `[{ channel: '&', phase: 'engaged' }]` // self mid-gesture
1302
- * `'initial'` → `[{ channel: '&', phase: 'initial' }]` // self idle
1303
- * `'*'` → `[{ channel: '&', phase: '*' }]` // either self phase
1304
- *
1305
- * Array form for explicit channel:phase atoms — e.g. `[{ channel: 'rect',
1306
- * phase: 'engaged' }]` for "when the rect tool is mid-gesture, regardless of
1307
- * which scope I'm in." See the v3 route grammar in
1308
- * `@weasel-js/gestures/grammar` for the full lattice.
1309
- *
1310
- * When omitted, matches in any phase (preserves pre-phase behavior). */
1311
- type PhaseSpec = 'initial' | 'engaged' | '*' | readonly PhaseAtom[];
1312
- /** Single-keystroke gesture (keydown). */
1313
- interface KeySpec {
1314
- kind: 'key';
1315
- /** A single key, or an array of acceptable keys (case-insensitive match). */
1316
- key: string | string[];
1317
- mods?: ModSpec;
1318
- phase?: PhaseSpec;
1319
- }
1320
- /** Key-held gesture (keydown opens, keyup closes). Drives "hold space for
1321
- * hand tool"-style interactions. */
1322
- interface KeyHeldSpec {
1323
- kind: 'key-held';
1324
- /** A single key, or an array of acceptable keys (case-insensitive match). */
1325
- key: string | string[];
1326
- mods?: ModSpec;
1327
- phase?: PhaseSpec;
1328
- }
1329
- /** Wheel-event gesture. `direction` filters by deltaY sign; default `'*'`.
1330
- * - `'up'` → matches only deltaY < 0
1331
- * - `'down'` → matches only deltaY > 0
1332
- * - `'*'` → matches either sign (default; universal-wildcard convention) */
1333
- interface WheelSpec {
1334
- kind: 'wheel';
1335
- direction?: 'up' | 'down' | '*';
1336
- mods?: ModSpec;
1337
- phase?: PhaseSpec;
1338
- }
1339
- /** Click gesture (pointerdown + pointerup without movement past the
1340
- * threshold). */
1341
- interface ClickSpec {
1342
- kind: 'click';
1343
- target?: TargetSpec;
1344
- mods?: ModSpec;
1345
- phase?: PhaseSpec;
1346
- }
1347
- /** Double-click: two `click` events within ~500ms and ~5px of each other.
1348
- * Synthesized by `useGestureDispatcher`; emitted AFTER the second
1349
- * `click`. Bindings that want to handle a double-click should declare
1350
- * this kind rather than chasing two `click` events. */
1351
- interface DoubleClickSpec {
1352
- kind: 'doubleClick';
1353
- target?: TargetSpec;
1354
- mods?: ModSpec;
1355
- phase?: PhaseSpec;
1356
- }
1357
- /** Right-click (contextmenu) gesture. The dispatcher calls
1358
- * `preventDefault()` on the underlying DOM event so the native menu
1359
- * doesn't appear — tools/actions fully own the right-click UX. */
1360
- interface ContextMenuSpec {
1361
- kind: 'contextMenu';
1362
- target?: TargetSpec;
1363
- mods?: ModSpec;
1364
- phase?: PhaseSpec;
1365
- }
1366
- /** Drag gesture (pointerdown + pointermove past the threshold). */
1367
- interface DragSpec {
1368
- kind: 'drag';
1369
- target?: TargetSpec;
1370
- mods?: ModSpec;
1371
- phase?: PhaseSpec;
1372
- }
1373
- /** Multi-touch gesture. `fingers` is the required touch count. */
1374
- interface MultiTouchSpec {
1375
- kind: 'multiTouch';
1376
- fingers: number;
1377
- mods?: ModSpec;
1378
- phase?: PhaseSpec;
1379
- }
1380
- /** Multi-touch tap gesture — fires when N fingers touch down then release
1381
- * together without movement past the tap threshold. Synthesized by the
1382
- * dispatcher from the underlying multitouch tracking. */
1383
- interface MultiTouchTapSpec {
1384
- kind: 'multiTouchTap';
1385
- fingers: number;
1386
- mods?: ModSpec;
1387
- phase?: PhaseSpec;
1388
- }
1389
- /** OS drag-and-drop of external content onto the canvas. `types` filters by
1390
- * MIME glob (`'image/*'`, `'text/plain'`); the spec matches when ANY item's
1391
- * MIME matches ANY glob. Omitted or empty = matches any drop. */
1392
- interface DropSpec {
1393
- kind: 'drop';
1394
- types?: string[];
1395
- mods?: ModSpec;
1396
- phase?: PhaseSpec;
1397
- }
1398
- /** System-clipboard paste of external content. Same `types` semantics as
1399
- * {@link DropSpec} — omitted or empty = matches any paste. */
1400
- interface PasteSpec {
1401
- kind: 'paste';
1402
- types?: string[];
1403
- mods?: ModSpec;
1404
- phase?: PhaseSpec;
1405
- }
1406
- /** The full union of supported gesture spec kinds. New invocation forms
1407
- * (long-press, two-stage, modal-dialog) extend this union without touching
1408
- * the `Action` type. */
1409
- type GestureSpec = KeySpec | KeyHeldSpec | WheelSpec | ClickSpec | DoubleClickSpec | ContextMenuSpec | DragSpec | MultiTouchSpec | MultiTouchTapSpec | DropSpec | PasteSpec;
1410
-
1411
- type ContainerBounds = {
1412
- x: number;
1413
- y: number;
1414
- width: number;
1415
- height: number;
1416
- };
1417
- interface LayoutChild<TPose> {
1269
+ interface InsertAdapter<TNode extends {
1418
1270
  id: string;
1419
- pose: TPose;
1420
- }
1421
- interface DropTarget<TPose> {
1422
- /** Where the dragged child lands if this target is picked. */
1423
- pose: TPose;
1424
- /** Reference point for distance metrics (snap algorithms). */
1425
- origin: {
1426
- x: number;
1427
- y: number;
1428
- };
1429
- /** Optional axis-aligned region (world units) used by region-aware snaps
1430
- * (e.g. `containedThenNearest`). When present, a pointer inside this rect
1431
- * is treated as a containment hit on this target. Strategies that emit
1432
- * region-shaped targets (gutters, drop-zones) should populate this.
1433
- * Strategies whose targets are point-like (free-form, snap-point) can omit
1434
- * it and rely on `origin`-distance snaps. */
1435
- hitBounds?: {
1271
+ }> {
1272
+ /** Materialize a new node from drag-rect bounds (drag-to-insert tools).
1273
+ * Optional hooks that don't drive insertion (e.g. `useClone`,
1274
+ * read-only clipboard) won't call it, and `sceneToAdapter` only fills
1275
+ * it in when `options.commitInsert` is supplied. Hooks that *do* call
1276
+ * it (insert tools) document the requirement at their own surface. */
1277
+ commitInsert?(bounds: {
1436
1278
  x: number;
1437
1279
  y: number;
1438
1280
  width: number;
1439
1281
  height: number;
1282
+ }): TNode | null;
1283
+ /** Materialize one or more new nodes from a clipboard snapshot. Optional
1284
+ * — required by `useClipboard.paste`, ignored by other consumers. */
1285
+ commitPaste?(clipboard: ClipboardSnapshot, offset: {
1286
+ dx: number;
1287
+ dy: number;
1288
+ }, ctx?: {
1289
+ dropPoint?: {
1290
+ worldX: number;
1291
+ worldY: number;
1292
+ };
1293
+ }): TNode[];
1294
+ /** Snapshot the current selection into the clipboard payload shape.
1295
+ * Optional — required by `useClipboard.copy` / `cut` and `useClone`'s
1296
+ * ghost capture, ignored by other consumers. */
1297
+ snapshotSelection?(ids: string[]): ClipboardSnapshot;
1298
+ getPasteOffset?(clipboard: ClipboardSnapshot): {
1299
+ dx: number;
1300
+ dy: number;
1440
1301
  };
1441
- /** Strategy-private metadata (e.g. cell coords for tile-grid). */
1442
- meta?: unknown;
1443
- }
1444
- interface LayoutSnap<TPose> {
1445
- pickTarget(targets: DropTarget<TPose>[], pointer: {
1446
- x: number;
1447
- y: number;
1448
- }): DropTarget<TPose> | null;
1449
- }
1450
- interface LayoutContainer {
1451
- id: string;
1452
- bounds: ContainerBounds;
1453
- }
1454
- interface LayoutDragged<TPose> {
1455
- id: string;
1456
- /** The pose the dragged child currently has (pre-drop). */
1457
- originPose: TPose;
1458
- /** The pose the gesture proposes (pointer-driven, pre-snap). */
1459
- pose: TPose;
1460
- sourceContainerId: string | null;
1461
- }
1462
- interface LayoutStrategy<TPose> {
1463
- childPoses(container: LayoutContainer, children: ReadonlyArray<LayoutChild<TPose>>): Map<string, TPose>;
1464
- getDropTargets(container: LayoutContainer, children: ReadonlyArray<LayoutChild<TPose>>, dragged: LayoutDragged<TPose>): DropTarget<TPose>[];
1465
- reflowPoses(container: LayoutContainer, children: ReadonlyArray<LayoutChild<TPose>>, dragged: LayoutDragged<TPose>, target: DropTarget<TPose> | null): Map<string, TPose>;
1466
- commitDrop(container: LayoutContainer, children: ReadonlyArray<LayoutChild<TPose>>, dragged: LayoutDragged<TPose>, target: DropTarget<TPose> | null): Op[];
1467
- snap: LayoutSnap<TPose>;
1468
- /** Optional: predicate for whether a world-space point is inside this
1469
- * container. When absent, callers fall back to an axis-aligned bounding-box
1470
- * test on the container's pose. Strategies whose containers aren't
1471
- * rectangular (circles, irregular zones) implement this to override the
1472
- * AABB default. */
1473
- contains?(containerPose: TPose, point: {
1474
- x: number;
1475
- y: number;
1476
- }): boolean;
1477
- }
1478
-
1479
- /**
1480
- * SnapTarget — where a dragged node would re-parent to if released.
1481
- *
1482
- * `slotPose` is the pose (in world coordinates) the node should snap to
1483
- * within the target. `metadata` is an opaque pass-through for app-specific
1484
- * snap details (slot index, visual hint, etc.).
1485
- */
1486
- interface SnapTarget<TPose = unknown> {
1487
- parentId: string;
1488
- slotPose: TPose;
1489
- metadata?: unknown;
1490
- }
1491
- /**
1492
- * Narrow adapter for `useMove`. Includes optional snap-target
1493
- * lookup; apps without container-snapping leave it out.
1494
- */
1495
- interface MoveAdapter<TNode extends {
1496
- id: string;
1497
- }, TPose> {
1498
- getNode(id: string): TNode | undefined;
1499
- /** Enumerate all nodes. `<Canvas>` derives a default rect-pose `pickEvery`
1500
- * and the scene-iteration loop from this. */
1501
- getNodes(): TNode[];
1502
- getPose(id: string): TPose;
1503
- /** Optional. Required only by hierarchy-aware paths: layout-pass drop
1504
- * targeting (`getLayout` present), nested-hit collapse
1505
- * (`pickTopMostHit`), and group-pose composition. Flat scenes may omit. */
1506
- getParent?(id: string): string | null;
1507
- setPose(id: string, pose: TPose): void;
1508
- /** Optional. Used only by reparent ops (e.g. drag-into-container drops via
1509
- * layout strategies). Flat scenes that never reparent may omit. */
1510
- setParent?(id: string, parentId: string | null): void;
1302
+ /** Mutator wired by `insertNode`-using ops (kit-side InsertOp). */
1303
+ insertNode(node: TNode): void;
1304
+ /** Mutator wired by `setSelection` ops batched alongside paste. */
1305
+ setSelection(ids: string[]): void;
1511
1306
  /** Optional: see SceneAdapter.applyOps. */
1512
1307
  applyOps?(ops: Op[], label: string): void;
1513
- findSnapTarget?(draggedId: string, worldX: number, worldY: number): SnapTarget<TPose> | null;
1514
- /** Optional: direct children of `id`. When present (alongside the
1515
- * `cascadeWorldPose` option on `useMove`), dragging a node
1516
- * auto-cascades its descendants in the live overlay so structurally-
1517
- * grouped children visually follow the parent during the drag. No
1518
- * additional ops are generated — children's local poses don't change
1519
- * when the parent's local pose moves. */
1520
- getChildren?(id: string): string[];
1521
- /** Optional: layout strategy attached to a container, or null if the
1522
- * container uses absolute positioning (default behavior). When present,
1523
- * `useMove` uses the strategy to compute drop targets, sibling reflow,
1524
- * and the commit op batch when a drag ends over the container. */
1525
- getLayout?(containerId: string): LayoutStrategy<TPose> | null;
1308
+ /** Returns the current selection. Used by clone behaviors. */
1309
+ getSelection(): string[];
1526
1310
  }
1527
1311
 
1528
1312
  /** Snapshot of modifier-key state at gesture dispatch. */
@@ -1581,25 +1365,1836 @@ interface ActionBehavior<TPose, TProposed, TMoveResult> {
1581
1365
  onMove?(ctx: GestureContext<TPose>, proposed: TProposed): TMoveResult | void;
1582
1366
  onEnd?(ctx: GestureContext<TPose>): Op[] | null | void;
1583
1367
  }
1368
+ /** Which corner/edge of the rect stays fixed during a resize. */
1369
+ type ResizeAnchor = {
1370
+ x: 'min' | 'max' | 'free';
1371
+ y: 'min' | 'max' | 'free';
1372
+ };
1373
+ /** Minimum rect-shaped pose required by the resize machinery. */
1374
+ interface ResizePose {
1375
+ x: number;
1376
+ y: number;
1377
+ width: number;
1378
+ height: number;
1379
+ }
1380
+ /** Per-frame proposed resize: pose plus the anchor pinning the opposite corner. */
1381
+ interface ResizeProposed<TPose extends ResizePose> {
1382
+ pose: TPose;
1383
+ anchor: ResizeAnchor;
1384
+ }
1385
+ /** Per-frame result a `BoundsConstraint.onMove` can return to override the proposed pose. */
1386
+ interface ResizeMoveResult<TPose extends ResizePose> {
1387
+ pose?: TPose;
1388
+ }
1389
+ /** A bounds-frame constraint plugged into `useResize` / `resizeAction`.
1390
+ * Reads/writes `{x,y,width,height}` and can override the proposed pose
1391
+ * on each frame (e.g. lock-aspect, clamp-min-size, snap-to-grid). */
1392
+ type BoundsConstraint<TPose extends ResizePose> = ActionBehavior<TPose, ResizeProposed<TPose>, ResizeMoveResult<TPose>>;
1393
+ /** Frames a point-snap behavior can return for the hook to back-solve. */
1394
+ type PointSnapFrame = 'dragged-corner' | 'fixed-corner' | 'center' | 'origin';
1395
+ /** Per-frame world-space context handed to `PointSnapBehavior.onMove`.
1396
+ * `draggedCorner` and `fixedCorner` are `null` for edge drags
1397
+ * (`anchor.x === 'free'` or `anchor.y === 'free'`). `center` and
1398
+ * `origin` are always present. */
1399
+ interface PointSnapContext<TPose extends ResizePose> {
1400
+ draggedCorner: {
1401
+ worldX: number;
1402
+ worldY: number;
1403
+ } | null;
1404
+ fixedCorner: {
1405
+ worldX: number;
1406
+ worldY: number;
1407
+ } | null;
1408
+ center: {
1409
+ worldX: number;
1410
+ worldY: number;
1411
+ };
1412
+ origin: {
1413
+ worldX: number;
1414
+ worldY: number;
1415
+ };
1416
+ rotation: number;
1417
+ anchor: ResizeAnchor;
1418
+ proposed: TPose;
1419
+ modifiers: ModifierState;
1420
+ }
1421
+ /** Per-frame snap result. A behavior returns at most one. */
1422
+ interface PointSnapResult {
1423
+ frame: PointSnapFrame;
1424
+ worldX: number;
1425
+ worldY: number;
1426
+ }
1427
+ /** A point-snap behavior plugged into `useResize`'s `pointSnapBehaviors`. */
1428
+ interface PointSnapBehavior<TPose extends ResizePose> {
1429
+ id?: string;
1430
+ onMove(ctx: PointSnapContext<TPose>): PointSnapResult | null | undefined;
1431
+ }
1584
1432
 
1585
- /** Per-invocation options the dispatcher reads from a `GestureBinding`'s
1586
- * `opts` field and passes to `OngoingInvoker.start`. Today carries
1587
- * behaviors; extensible. */
1588
- interface BindingOpts {
1589
- behaviors?: ActionBehavior<unknown, unknown, unknown>[];
1590
- /** Per-binding action parameters. The action's invoker reads
1591
- * these via the second arg to `run` (or via InvocationCtx for ongoing
1592
- * invokers, when needed). Loose typing (Record<string, unknown>) for
1593
- * now; consider per-action typing later via BindingOpts<A>.
1433
+ /**
1434
+ * @experimental
1435
+ * Result of an affordance hit — what the region computed about itself.
1436
+ *
1437
+ * `initialScratch` is the payload: what the region already knows (which
1438
+ * corner, which target id) so the action that picks up the drag doesn't
1439
+ * re-derive it. `<SceneCanvas>` reads it out of the layer hit-test and packs
1440
+ * it into `AffordanceHit`, which flows to the matching action through
1441
+ * `InvocationCtx.drag.affordance`.
1442
+ *
1443
+ * This used to also carry a `drag: DragChannel` naming the handlers the
1444
+ * tool-routing dispatcher should wire up. Every implementation supplied a
1445
+ * no-op stub that claimed, because the real routing had already moved to
1446
+ * bindings; the field went with that dispatcher.
1447
+ */
1448
+ interface AffordanceBinding<TScratch = unknown> {
1449
+ initialScratch?: TScratch;
1450
+ }
1451
+
1452
+ /**
1453
+ * Canvas size in CSS pixels — passed to `draw` for layers that anchor to
1454
+ * canvas edges (e.g. the debug overlay's layer-list panel). The GL backend
1455
+ * supplies it explicitly so layers don't have to know about DPR.
1456
+ */
1457
+ interface Dims {
1458
+ width: number;
1459
+ height: number;
1460
+ }
1461
+ /**
1462
+ * A single named render sub-layer within a canvas renderer.
1463
+ *
1464
+ * @template TData - The data object passed to each draw call.
1465
+ */
1466
+ interface RenderLayer<TData> {
1467
+ /** Unique identifier used in visibility maps and ordering arrays. */
1468
+ id: string;
1469
+ /** Human-readable name for UI toggles. */
1470
+ label: string;
1471
+ /**
1472
+ * Emit a DrawCommand tree for the GL backend to dispatch.
1473
+ *
1474
+ * For world-space layers (the default), emit commands in WORLD COORDS —
1475
+ * `drawLayers` automatically wraps them in `{ kind: 'group', transform:
1476
+ * viewToMat3(view), ... }` before handing them to the renderer. Do NOT
1477
+ * apply the view transform yourself.
1478
+ *
1479
+ * For screen-space layers (`space: 'screen'`), emit commands in CSS-pixel
1480
+ * coords directly; `drawLayers` passes them through unchanged. If part
1481
+ * of a screen-space layer's output needs to track the view, wrap that
1482
+ * subset manually with `viewToMat3(view)`.
1483
+ */
1484
+ draw: (data: TData, view: View, dims: Dims) => DrawCommand[];
1485
+ /**
1486
+ * Whether the layer is shown when no explicit visibility entry exists.
1487
+ * Defaults to `true` when absent.
1488
+ */
1489
+ defaultVisible?: boolean;
1490
+ /**
1491
+ * When true, the layer is always drawn regardless of the visibility map.
1492
+ * Useful for layers that must never be hidden (e.g. base grid).
1493
+ */
1494
+ alwaysOn?: boolean;
1495
+ /**
1496
+ * Coordinate space the layer draws in.
1497
+ *
1498
+ * - `'world'` (default): the layer's `draw` returns world-space commands;
1499
+ * `drawLayers` wraps them in a `kind: 'group'` with `viewToMat3(view)`
1500
+ * automatically.
1501
+ * - `'screen'`: the layer's `draw` returns screen-space (CSS-pixel)
1502
+ * commands; `drawLayers` passes them through unchanged. World-anchored
1503
+ * chrome inside a screen-space layer must call `worldToScreen` or wrap
1504
+ * the relevant subset with `viewToMat3(view)` manually.
1505
+ */
1506
+ space?: 'world' | 'screen';
1507
+ /**
1508
+ * Optional hit-test for **consumer-attached** layers.
1509
+ *
1510
+ * Only layers registered through `CanvasExtensionApi.registerLayer` are
1511
+ * hit-tested: `hitTestExtras` walks them last-registered-first on
1512
+ * pointerdown, and `<SceneCanvas>` folds the result into its `affordanceAt`
1513
+ * thunk ahead of the kit's own selection chrome. First non-null result
1514
+ * wins; null means "I don't claim this hit, try the next layer."
1515
+ *
1516
+ * Layers that reach the draw stack some other way — a `Tool.overlay`, an
1517
+ * entry in the `layers` map — are painted but never hit-tested, so defining
1518
+ * `hitTest` on one has no effect. (The kit's own chrome doesn't need it: it
1519
+ * goes through `buildAffordanceAt`.)
1520
+ *
1521
+ * Coordinates are world-space. The `data` arg is the layer's
1522
+ * configured data slot (same as `draw`); `view` and `dims` mirror
1523
+ * `draw`'s arguments.
1524
+ */
1525
+ hitTest?: (worldX: number, worldY: number, data: TData, view: View, dims: Dims,
1526
+ /** Chrome-caps visibility predicate. When supplied, the layer must
1527
+ * not return a hit from any chrome element whose id reports
1528
+ * `false`. Absent → every element is hittable. */
1529
+ isVisible?: (id: string) => boolean) => AffordanceBinding | null;
1530
+ /**
1531
+ * Called on every pointermove when no gesture is currently captured.
1532
+ * Lets layers (e.g. HUD widgets) track hover state without participating
1533
+ * in the drag pipeline. Coords are world-space; the layer is responsible
1534
+ * for any further conversion (e.g. world→screen for screen-space layers)
1535
+ * and for its own throttling.
1536
+ */
1537
+ onUncapturedMove?: (worldX: number, worldY: number, evt: PointerEvent, view: View, dims: Dims) => void;
1538
+ /**
1539
+ * Called when the cursor leaves the canvas element. Lets layers clear
1540
+ * any hover state they're holding.
1541
+ */
1542
+ onUncapturedLeave?: () => void;
1543
+ }
1544
+
1545
+ /** The full vocabulary of capability tags shipped in the default preset.
1546
+ * Apps and other consumers can add their own tags; this list is what
1547
+ * `weasel-modes` itself uses. */
1548
+ declare const ALL_TAGS: readonly ["navigation", "creates-selection", "creates-paths", "creates-shapes", "creates-text", "edits-anchors", "edits-text", "transforms-selection", "samples-color", "applies-fill", "edits-page"];
1549
+ type CapabilityTag = (typeof ALL_TAGS)[number] | (string & {});
1550
+
1551
+ interface WorkspaceVisual {
1552
+ tint?: string;
1553
+ gradient?: 'top-down' | 'bottom-up';
1554
+ intensity?: number;
1555
+ }
1556
+ interface ModeDefinition {
1557
+ id: string;
1558
+ kind: 'soft' | 'strict';
1559
+ /** Capability tags this mode allows beyond IMPLICIT_TAGS. */
1560
+ allows: CapabilityTag[];
1561
+ /** When true, out-of-target objects dim at the renderer layer. */
1562
+ scoping: boolean;
1563
+ workspace?: WorkspaceVisual;
1564
+ entry?: {
1565
+ shortcut?: string;
1566
+ trigger?: 'double-click-target';
1567
+ };
1568
+ exit?: {
1569
+ shortcut?: string;
1570
+ };
1571
+ commit?: {
1572
+ shortcut?: string;
1573
+ };
1574
+ cancel?: {
1575
+ shortcut?: string;
1576
+ };
1577
+ }
1578
+
1579
+ interface ModeRegistry {
1580
+ current(): ModeDefinition;
1581
+ setMode(id: string): void;
1582
+ byId(id: string): ModeDefinition;
1583
+ getVersion(): number;
1584
+ subscribe(listener: () => void): () => void;
1585
+ }
1586
+
1587
+ /**
1588
+ * Live state read by rule evaluation. Built once per frame on the consuming
1589
+ * surface — chrome-caps, the affordance pipeline, the dispatcher's
1590
+ * eligibility filter — and discarded.
1591
+ *
1592
+ * Adding a new field is additive: existing rules don't change, new
1593
+ * selector atoms can read it.
1594
+ */
1595
+ interface RuleCtx {
1596
+ readonly focused: boolean;
1597
+ readonly selection: readonly NodeId[];
1598
+ readonly multiActive: boolean;
1599
+ readonly modifiers: ModifierState;
1600
+ readonly action: {
1601
+ readonly kind: string | null;
1602
+ readonly id: string | null;
1603
+ };
1604
+ readonly hover: NodeId | null;
1605
+ readonly view: View;
1606
+ /** Active mode id. `'normal'` when no non-default mode is engaged. */
1607
+ readonly mode: string;
1608
+ /** Capability tags allowed by the active mode (the union of
1609
+ * `ModeDefinition.allows` plus implicit tags). The `capability:`
1610
+ * selector reads this to determine whether a tag is permitted. */
1611
+ readonly allowedCapabilities: ReadonlySet<CapabilityTag>;
1612
+ /** Whether the current selection may be resized. `<SceneCanvas>` folds
1613
+ * `selectTool.resize.resizable` over the selection (true only when every
1614
+ * selected node is resizable). Read by the `resizable:` selector to gate
1615
+ * `selection.resize-handles`. Absent (legacy ctx builders) is treated as
1616
+ * resizable — back-compat: handles show unless a consumer opts a node out. */
1617
+ readonly selectionResizable?: boolean;
1618
+ /** Whether a path is currently in anchor-edit mode. Read by the
1619
+ * `editingAnchors:` selector, which gates the path-edit chrome.
1620
+ *
1621
+ * This is deliberately a fact about state, not about permission: the
1622
+ * anchor overlay and the anchor hit-test must agree, and the thing they
1623
+ * must agree on is "is there an edited path right now", which no
1624
+ * capability or mode id answers. A mode that allows `edits-anchors`
1625
+ * with nothing being edited should draw no anchors. Absent is treated
1626
+ * as false. */
1627
+ readonly editingAnchors?: boolean;
1628
+ }
1629
+
1630
+ /**
1631
+ * A selector is a conjunction of key/value tests. Multiple keys at the same
1632
+ * level AND together. Each key maps to a selector primitive in the evaluator.
1633
+ */
1634
+ interface Selector {
1635
+ selection?: {
1636
+ is?: number;
1637
+ atLeast?: number;
1638
+ empty?: boolean;
1639
+ };
1640
+ mode?: string | {
1641
+ not: string;
1642
+ } | {
1643
+ in: readonly string[];
1644
+ };
1645
+ capability?: CapabilityTag | readonly CapabilityTag[] | {
1646
+ in: readonly CapabilityTag[];
1647
+ } | {
1648
+ not: CapabilityTag;
1649
+ };
1650
+ gesturing?: boolean;
1651
+ actionIs?: string;
1652
+ modifierHeld?: keyof ModifierState;
1653
+ focused?: boolean;
1654
+ hovering?: boolean;
1655
+ hoveringSelected?: boolean;
1656
+ zoomAtLeast?: number;
1657
+ /** Matches `ctx.editingAnchors` — true while a path is in anchor-edit
1658
+ * mode. Absent flag is treated as `false`. */
1659
+ editingAnchors?: boolean;
1660
+ /** Matches `ctx.selectionResizable`. Absent flag is treated as `true`
1661
+ * (resizable), so `{ resizable: true }` passes for legacy ctx builders
1662
+ * that don't compute it. */
1663
+ resizable?: boolean;
1664
+ }
1665
+ /**
1666
+ * Composable visibility/eligibility rule. Trees of `all`/`any`/`not` nodes
1667
+ * over `Selector` leaves. `when` is the escape hatch — its closure is
1668
+ * opaque to introspection and should be avoided when a declarative form
1669
+ * exists. Empty `all` is true; empty `any` is false.
1670
+ */
1671
+ type Rule = Selector | {
1672
+ all: readonly Rule[];
1673
+ } | {
1674
+ any: readonly Rule[];
1675
+ } | {
1676
+ not: Rule;
1677
+ } | {
1678
+ when: (ctx: RuleCtx) => boolean;
1679
+ };
1680
+
1681
+ /**
1682
+ * Composable visibility predicate with fluent surface. Carries its underlying
1683
+ * `Rule` tree at `.rule` so the resolver can introspect / share trees with
1684
+ * the affordance pipeline and the dispatcher's eligibility filter.
1685
+ *
1686
+ * Callable form `cond(ctx)` evaluates the tree against ctx. The fluent
1687
+ * methods return new Conditions wrapping new trees.
1688
+ *
1689
+ * **Chain semantics: strict left-to-right, no precedence.**
1690
+ * `a.and(b).or(c)` is `(a && b) || c`; `a.or(b).and(c)` is
1691
+ * `(a || b) && c`. Mix `.and` and `.or` only when you mean
1692
+ * left-to-right evaluation. For grouped disjunction, name the
1693
+ * subexpression or use the top-level `or(...)`.
1694
+ */
1695
+ interface Condition {
1696
+ (ctx: RuleCtx): boolean;
1697
+ readonly rule: Rule;
1698
+ /** `this && other` */
1699
+ and(other: Condition | Rule): Condition;
1700
+ /** `this || other` */
1701
+ or(other: Condition | Rule): Condition;
1702
+ /** `this && !other` */
1703
+ andNot(other: Condition | Rule): Condition;
1704
+ /** `this || !other` */
1705
+ orNot(other: Condition | Rule): Condition;
1706
+ }
1707
+
1708
+ /** Phase of a gesture lifecycle. `initial` means the tool is idle
1709
+ * (scratch null); `engaged` means a gesture is in progress (scratch
1710
+ * populated). The route-grammar's `[phase]` slot draws from this set. */
1711
+ type RoutePhase = 'initial' | 'engaged';
1712
+
1713
+ /**
1714
+ * Route-string grammar v3:
1715
+ *
1716
+ * route = phaseSlot WS gesture WS argSlot? WS targetSlot? WS modSlot?
1717
+ * phaseSlot = '[' phaseList ']'
1718
+ * phaseList = phaseAtom (WS ',' WS phaseAtom)*
1719
+ * phaseAtom = (channel ':')? phaseValue -- bare phaseValue ≡ '&:phaseValue'
1720
+ * channel = '&' | '*' | toolId -- '&' = the binding's own tool
1721
+ * phaseValue = 'initial' | 'engaged' | '*'
1722
+ * argSlot = '(' argValue ')' -- whitespace inside parens is significant
1723
+ * targetSlot = '=>' WS targetValue -- omitted slot defaults to '*' for hasTarget
1724
+ * modSlot = modAtom (WS modAtom)*
1725
+ * modAtom = sigil modName
1726
+ * sigil = '+' | '?' -- ! @ # $ % ^ & * reserved as id-prefix
1727
+ * modName = 'mod' | 'shift' | 'alt' | 'ctrl' | 'meta'
1728
+ *
1729
+ * Shorthand: a bare phaseValue (no `:`) implies channel `&` ("this tool's
1730
+ * own phase"). `[engaged]` ≡ `[&:engaged]`; `[*]` ≡ `[&:*]`. The truly-loose
1731
+ * form (any channel, any phase) is `[*:*]`.
1732
+ *
1733
+ * Examples:
1734
+ * [initial] click => empty +shift -- self idle
1735
+ * [engaged] wheel -- self mid-gesture
1736
+ * [rect:engaged] wheel -- when rect tool is mid-gesture
1737
+ * [*:engaged] keyDown(Delete) -- when any tool is mid-gesture
1738
+ * [initial,engaged] contextMenu => empty -- either self phase
1739
+ * [*] click => empty -- self, any phase
1740
+ */
1741
+
1742
+ /** Channel reference for a phase atom. `'&'` = the binding's own tool;
1743
+ * `'*'` = any tool; otherwise a registered tool id. */
1744
+ type ChannelRef = '&' | '*' | string;
1745
+ /** One element of a phase list: a (channel, phase) pair. The default
1746
+ * channel (omitted in the shorthand) is `'&'`. `phase: '*'` means
1747
+ * "any phase of the given channel". */
1748
+ interface PhaseAtom {
1749
+ channel: ChannelRef;
1750
+ phase: RoutePhase | '*';
1751
+ }
1752
+
1753
+ /**
1754
+ * GestureSpec — describes the form of a user input event that can fire an action.
1755
+ *
1756
+ * Used by `Action.defaultBinding` (the action's preferred gesture) and by
1757
+ * `GestureBinding.spec` (a tool's binding table entry). The dispatcher matches
1758
+ * incoming input events against registered specs to determine which action to
1759
+ * invoke.
1760
+ *
1761
+ * See `docs/superpowers/specs/2026-05-16-registry-unification-design.md` § "Types".
1762
+ */
1763
+ /** Optional modifier-key requirement for a gesture spec.
1764
+ *
1765
+ * Matching semantics (strict): an omitted modifier field means the
1766
+ * modifier MUST NOT be held — i.e., a bare `{ kind: 'key', key: 'Escape' }`
1767
+ * matches only unmodified Escape, NOT Cmd+Escape. A `true` means the
1768
+ * modifier MUST be held; `false` is the same as omitted (must be absent).
1769
+ * This mirrors today's `KeyBinding` matcher and keeps conflict detection
1770
+ * coherent.
1771
+ *
1772
+ * `mod` is a platform-aware shorthand: matches `metaKey` on mac, `ctrlKey`
1773
+ * elsewhere (mirrors `KeyBinding.mod`).
1774
+ *
1775
+ * `shift` additionally accepts `'optional'` meaning "shifted or unshifted
1776
+ * both acceptable" — the explicit opt-in for loose matching, used by
1777
+ * actions like nudge whose step size depends on shift but whose firing
1778
+ * does not. To widen other modifiers similarly, extend their type when
1779
+ * a real consumer needs it.
1780
+ */
1781
+ type ModSpec = Partial<{
1782
+ alt: boolean | 'optional';
1783
+ ctrl: boolean | 'optional';
1784
+ meta: boolean | 'optional';
1785
+ mod: boolean | 'optional';
1786
+ shift: boolean | 'optional';
1787
+ }>;
1788
+ /** Target selector for click and drag gesture specs. String forms are sugar
1789
+ * for the kit-owned object-kind registry (TODO.md Tier 1 follow-up); until
1790
+ * that ships, consumers can pass `{ kindOf: predicate }` to classify hits
1791
+ * themselves. */
1792
+ type TargetSpec = 'empty' | 'selected-body' | 'unselected-body' | `kind:${string}` | `kind:${string}:selected` | `affordance:${string}` | {
1793
+ /** Predicate. `hit` is the raw target (affordance for drag,
1794
+ * `e.target` otherwise); `bodyTarget` is the optional body-class
1795
+ * string ('empty' | 'selected-body' | 'unselected-body') when
1796
+ * `classifyTarget` is wired. Predicates that only need one of the
1797
+ * two can ignore the other. */
1798
+ kindOf: (hit: unknown, bodyTarget?: string) => boolean;
1799
+ };
1800
+ /** Phase qualifier on a gesture spec. Restricts when the spec matches based
1801
+ * on per-tool gesture-lifecycle state.
1802
+ *
1803
+ * Shorthand forms (most common case — gate on the binding's own tool):
1804
+ * `'engaged'` → `[{ channel: '&', phase: 'engaged' }]` // self mid-gesture
1805
+ * `'initial'` → `[{ channel: '&', phase: 'initial' }]` // self idle
1806
+ * `'*'` → `[{ channel: '&', phase: '*' }]` // either self phase
1807
+ *
1808
+ * Array form for explicit channel:phase atoms — e.g. `[{ channel: 'rect',
1809
+ * phase: 'engaged' }]` for "when the rect tool is mid-gesture, regardless of
1810
+ * which scope I'm in." See the v3 route grammar in
1811
+ * `@weasel-js/gestures/grammar` for the full lattice.
1812
+ *
1813
+ * When omitted, matches in any phase (preserves pre-phase behavior). */
1814
+ type PhaseSpec = 'initial' | 'engaged' | '*' | readonly PhaseAtom[];
1815
+ /** Single-keystroke gesture (keydown). */
1816
+ interface KeySpec {
1817
+ kind: 'key';
1818
+ /** A single key, or an array of acceptable keys (case-insensitive match). */
1819
+ key: string | string[];
1820
+ mods?: ModSpec;
1821
+ phase?: PhaseSpec;
1822
+ }
1823
+ /** Key-held gesture (keydown opens, keyup closes). Drives "hold space for
1824
+ * hand tool"-style interactions. */
1825
+ interface KeyHeldSpec {
1826
+ kind: 'key-held';
1827
+ /** A single key, or an array of acceptable keys (case-insensitive match). */
1828
+ key: string | string[];
1829
+ mods?: ModSpec;
1830
+ phase?: PhaseSpec;
1831
+ }
1832
+ /** Wheel-event gesture. `direction` filters by deltaY sign; default `'*'`.
1833
+ * - `'up'` → matches only deltaY < 0
1834
+ * - `'down'` → matches only deltaY > 0
1835
+ * - `'*'` → matches either sign (default; universal-wildcard convention) */
1836
+ interface WheelSpec {
1837
+ kind: 'wheel';
1838
+ direction?: 'up' | 'down' | '*';
1839
+ mods?: ModSpec;
1840
+ phase?: PhaseSpec;
1841
+ }
1842
+ /** Click gesture (pointerdown + pointerup without movement past the
1843
+ * threshold). */
1844
+ interface ClickSpec {
1845
+ kind: 'click';
1846
+ target?: TargetSpec;
1847
+ mods?: ModSpec;
1848
+ phase?: PhaseSpec;
1849
+ }
1850
+ /** Double-click: two `click` events within ~500ms and ~5px of each other.
1851
+ * Synthesized by `useGestureDispatcher`; emitted AFTER the second
1852
+ * `click`. Bindings that want to handle a double-click should declare
1853
+ * this kind rather than chasing two `click` events. */
1854
+ interface DoubleClickSpec {
1855
+ kind: 'doubleClick';
1856
+ target?: TargetSpec;
1857
+ mods?: ModSpec;
1858
+ phase?: PhaseSpec;
1859
+ }
1860
+ /** Right-click (contextmenu) gesture. The dispatcher calls
1861
+ * `preventDefault()` on the underlying DOM event so the native menu
1862
+ * doesn't appear — tools/actions fully own the right-click UX. */
1863
+ interface ContextMenuSpec {
1864
+ kind: 'contextMenu';
1865
+ target?: TargetSpec;
1866
+ mods?: ModSpec;
1867
+ phase?: PhaseSpec;
1868
+ }
1869
+ /** Drag gesture (pointerdown + pointermove past the threshold). */
1870
+ interface DragSpec {
1871
+ kind: 'drag';
1872
+ target?: TargetSpec;
1873
+ mods?: ModSpec;
1874
+ phase?: PhaseSpec;
1875
+ }
1876
+ /**
1877
+ * Bare pointer press, matched at down time — before the dispatcher knows
1878
+ * whether the gesture will become a click or a drag.
1879
+ *
1880
+ * Reach for this only when the effect must be visible while the button is
1881
+ * still held. Selection is the motivating case: pressing an unselected node
1882
+ * highlights it immediately, and the drag that may follow then starts from an
1883
+ * already-correct selection. Anything that can wait for the release belongs on
1884
+ * a `click` spec, which does not fire on a press that turns into a drag.
1885
+ *
1886
+ * A matching binding does NOT own the gesture: the same press goes on to open
1887
+ * a drag or synthesize a click as usual. Bind an immediate action here, not an
1888
+ * ongoing one.
1889
+ */
1890
+ interface PointerDownSpec {
1891
+ kind: 'pointerDown';
1892
+ target?: TargetSpec;
1893
+ mods?: ModSpec;
1894
+ phase?: PhaseSpec;
1895
+ }
1896
+ /** Multi-touch gesture. `fingers` is the required touch count. */
1897
+ interface MultiTouchSpec {
1898
+ kind: 'multiTouch';
1899
+ fingers: number;
1900
+ mods?: ModSpec;
1901
+ phase?: PhaseSpec;
1902
+ }
1903
+ /** Multi-touch tap gesture — fires when N fingers touch down then release
1904
+ * together without movement past the tap threshold. Synthesized by the
1905
+ * dispatcher from the underlying multitouch tracking. */
1906
+ interface MultiTouchTapSpec {
1907
+ kind: 'multiTouchTap';
1908
+ fingers: number;
1909
+ mods?: ModSpec;
1910
+ phase?: PhaseSpec;
1911
+ }
1912
+ /** OS drag-and-drop of external content onto the canvas. `types` filters by
1913
+ * MIME glob (`'image/*'`, `'text/plain'`); the spec matches when ANY item's
1914
+ * MIME matches ANY glob. Omitted or empty = matches any drop. */
1915
+ interface DropSpec {
1916
+ kind: 'drop';
1917
+ types?: string[];
1918
+ mods?: ModSpec;
1919
+ phase?: PhaseSpec;
1920
+ }
1921
+ /** System-clipboard paste of external content. Same `types` semantics as
1922
+ * {@link DropSpec} — omitted or empty = matches any paste. */
1923
+ interface PasteSpec {
1924
+ kind: 'paste';
1925
+ types?: string[];
1926
+ mods?: ModSpec;
1927
+ phase?: PhaseSpec;
1928
+ }
1929
+ /** The full union of supported gesture spec kinds. New invocation forms
1930
+ * (long-press, two-stage, modal-dialog) extend this union without touching
1931
+ * the `Action` type. */
1932
+ type GestureSpec = KeySpec | KeyHeldSpec | WheelSpec | ClickSpec | DoubleClickSpec | ContextMenuSpec | DragSpec | PointerDownSpec | MultiTouchSpec | MultiTouchTapSpec | DropSpec | PasteSpec;
1933
+
1934
+ /** A 2D point in either world or screen coordinates. */
1935
+ interface Point2 {
1936
+ x: number;
1937
+ y: number;
1938
+ }
1939
+ /**
1940
+ * Information about which UI affordance was hit at pointerdown.
1941
+ *
1942
+ * Populated by the dispatcher when the `affordanceAt` thunk is provided to
1943
+ * `useGestureDispatcher`. Tools / action invokers that only fire on a specific
1944
+ * affordance (e.g. a resize handle) use this field as a guard — if the
1945
+ * affordance is absent or is the wrong kind, they return `{}` and let other
1946
+ * bindings handle the drag.
1947
+ *
1948
+ * `kind` is a discriminator string:
1949
+ * - `'handle:top-left'` / `'handle:top-right'` / `'handle:bottom-left'` /
1950
+ * `'handle:bottom-right'` — corner resize handles.
1951
+ * - `'rotate-handle'` — the rotation affordance.
1952
+ * - `'anchor:N'` — a path anchor at index N.
1953
+ *
1954
+ * `fixedPoint` is the world-space point that should remain stationary during
1955
+ * the gesture. For resize handles this is the opposite (diagonally fixed)
1956
+ * corner; for rotate it is the pivot.
1957
+ *
1958
+ * `targetIds` are the node ids this affordance belongs to.
1959
+ */
1960
+ interface AffordanceHit {
1961
+ /** Discriminator string, e.g. `'handle:bottom-right'`. */
1962
+ kind: string;
1963
+ /** World-space fixed/pivot point. For resize: opposite corner. For rotate: pivot. */
1964
+ fixedPoint?: {
1965
+ x: number;
1966
+ y: number;
1967
+ };
1968
+ /** Which nodes this affordance belongs to. */
1969
+ targetIds?: string[];
1970
+ /** Set when `kind` matches `'handle:*'`. Identifies which corner stays
1971
+ * fixed during a resize so consumers (resizeAction) don't re-parse `kind`.
1972
+ * Other affordance kinds (rotate-handle, anchor:N, controlIn:N, controlOut:N)
1973
+ * leave this undefined. */
1974
+ anchor?: ResizeAnchor;
1975
+ /** CSS cursor to show while the pointer hovers this affordance (no
1976
+ * gesture in flight). Consumed by the hover-cursor pump in
1977
+ * `useGestureDispatcher`; unset = the pump falls through to
1978
+ * action-cursor prediction, then to the active tool's cursor. */
1979
+ cursor?: string;
1980
+ /**
1981
+ * Free-form payload from whatever produced the hit, carried through to the
1982
+ * matching action untouched.
1983
+ *
1984
+ * Kit affordances describe themselves fully in the fields above and leave
1985
+ * this unset. It exists for affordances the kit doesn't know the shape of —
1986
+ * a registered layer's own chrome, where the hit-test already resolved
1987
+ * *which* of its pieces was hit and the action would otherwise have to
1988
+ * redo that work. `@weasel-js/hud` passes the hit widget here.
1989
+ */
1990
+ payload?: unknown;
1991
+ }
1992
+ /**
1993
+ * One accumulated point on a drag trail: world-space position plus whatever
1994
+ * stylus state the originating `PointerEvent` carried.
1995
+ *
1996
+ * The stylus fields are absent for mouse/touch on browsers that don't report
1997
+ * them, and for synthetic events. Consumers that want pressure-driven output
1998
+ * (e.g. `Stroke.vertexWidths` from a pencil stroke) read them off the samples
1999
+ * their `insert` dep receives — see `apps/site/demos/VertexWidthsDemo.tsx`.
2000
+ */
2001
+ interface DragSample extends Point2 {
2002
+ /** 0..1. Mouse/touch report 0.5 while a button is held, per the spec. */
2003
+ pressure?: number;
2004
+ /** Degrees, ±90. Zero for mouse/touch. */
2005
+ tiltX?: number;
2006
+ /** Degrees, ±90. Zero for mouse/touch. */
2007
+ tiltY?: number;
2008
+ }
2009
+ /** Per-invocation runtime context the dispatcher hands to an Invoker.
2010
+ * Gesture-kind-specific fields (`drag`, `wheel`, `multiTouch`, `key`) are
2011
+ * populated only for matching gesture kinds. */
2012
+ interface InvocationCtx {
2013
+ world: Point2;
2014
+ screen: Point2;
2015
+ modifiers: ModifierState;
2016
+ deps: ActionDeps;
2017
+ drag?: {
2018
+ start: Point2;
2019
+ current: Point2;
2020
+ delta: Point2;
2021
+ /**
2022
+ * Drag delta in client/screen coordinates (CSS pixels from the drag
2023
+ * origin). Use this — never `delta` — for any action whose effect
2024
+ * mutates the viewport itself (pan, view-zoom), because world-space
2025
+ * deltas become self-referential as the view shifts mid-drag.
2026
+ *
2027
+ * Populated when the dispatcher received `clientX`/`clientY` on the
2028
+ * underlying pointer events. Absent for legacy callers that don't
2029
+ * provide them.
2030
+ */
2031
+ screenDelta?: Point2;
2032
+ affordance?: AffordanceHit;
2033
+ /**
2034
+ * Full pointermove history for the current drag, in world space, with
2035
+ * per-sample stylus state when the browser reported it.
2036
+ * Accumulated by the dispatcher on every `pointermove` pump event.
2037
+ * Available only during `onMove` and `onEnd` calls (not on `start`).
2038
+ * Used by `lassoSelectAction` to build its polygon vertex list and by
2039
+ * `insertAction`'s pencil kind to carry the freehand stroke.
2040
+ */
2041
+ points?: DragSample[];
2042
+ };
2043
+ wheel?: {
2044
+ deltaX: number;
2045
+ deltaY: number;
2046
+ deltaZ: number;
2047
+ };
2048
+ multiTouch?: {
2049
+ centroid: Point2;
2050
+ spread: number;
2051
+ rotation: number;
2052
+ /**
2053
+ * Pinch-zoom geometry. Populated by the dispatcher when a multitouch
2054
+ * handle is in flight and a pointermove-pump fires.
2055
+ * `startSpread` is the spread at the moment the gesture began.
2056
+ * `currentSpread` is the spread at the current frame.
2057
+ */
2058
+ pinch?: {
2059
+ startSpread: number;
2060
+ currentSpread: number;
2061
+ centroid: Point2;
2062
+ };
2063
+ };
2064
+ key?: {
2065
+ key: string;
2066
+ repeat: boolean;
2067
+ };
2068
+ /**
2069
+ * Per-invocation parameters. Populated by `ActionsRegistry.begin()` for
2070
+ * UI-driven ongoing actions (color picker, opacity slider) so handles can
2071
+ * read the current value on `start` and updated values on `onMove`. The
2072
+ * gesture dispatcher does not populate this field; gesture-driven actions
2073
+ * receive params via `BindingOpts.params` on `start` (the `opts` arg).
2074
+ */
2075
+ params?: Record<string, unknown>;
2076
+ }
2077
+ /** Per-invocation options the dispatcher reads from a `GestureBinding`'s
2078
+ * `opts` field and passes to `OngoingInvoker.start`. Today carries
2079
+ * behaviors; extensible. */
2080
+ interface BindingOpts {
2081
+ behaviors?: ActionBehavior<unknown, unknown, unknown>[];
2082
+ /** Per-binding action parameters. The action's invoker reads
2083
+ * these via the second arg to `run` (or via InvocationCtx for ongoing
2084
+ * invokers, when needed). Loose typing (Record<string, unknown>) for
2085
+ * now; consider per-action typing later via BindingOpts<A>.
2086
+ *
2087
+ * params may also be a thunk evaluated each time the
2088
+ * dispatcher (or invoker) needs the value. Thunks let tools close over
2089
+ * refs that mutate during a gesture (e.g. polygon `sides` adjusted
2090
+ * mid-drag via ArrowUp). For ongoing invokers that want the latest
2091
+ * values at commit, the invoker can re-call the thunk inside `onEnd`
2092
+ * via `resolveParams(opts?.params)`. */
2093
+ params?: Record<string, unknown> | (() => Record<string, unknown>);
2094
+ }
2095
+ /** Convention-shaped action dependencies bag. Actions declare which
2096
+ * contexts they consume; the dispatcher composes them per call.
2097
+ * Consumer-side contexts (e.g. ColorContext) plug in by extending. */
2098
+ interface ActionDeps {
2099
+ selection?: unknown;
2100
+ view?: unknown;
2101
+ scene?: unknown;
2102
+ pointer?: unknown;
2103
+ activeTool?: unknown;
2104
+ [k: string]: unknown;
2105
+ }
2106
+ /**
2107
+ * Discriminated overlay shape returned by `OngoingHandle.overlay()`.
2108
+ * Dispatcher-side chrome surface for in-flight
2109
+ * gestures that paint non-ghost visuals. The canvas's
2110
+ * `useDispatcherOverlayLayer` walks every in-flight handle, calls
2111
+ * `overlay()`, and dispatches on `kind` to draw the appropriate shape.
2112
+ *
2113
+ * `marquee` mirrors `AreaSelectOverlay`; `lasso` mirrors `LassoSelectOverlay`.
2114
+ * `commands` is the generic escape hatch — actions emit arbitrary
2115
+ * `DrawCommand[]` for previews the typed variants can't express (insert
2116
+ * shape outlines, paste ghosts of synthetic nodes, custom chrome). World-
2117
+ * space is the default; the layer wraps in `viewToMat3` so commands track
2118
+ * the camera. Set `space: 'screen'` for projections you've already done
2119
+ * yourself (rare).
2120
+ */
2121
+ type OngoingOverlay = {
2122
+ kind: 'marquee';
2123
+ start: {
2124
+ x: number;
2125
+ y: number;
2126
+ };
2127
+ current: {
2128
+ x: number;
2129
+ y: number;
2130
+ };
2131
+ shiftHeld: boolean;
2132
+ } | {
2133
+ kind: 'lasso';
2134
+ vertices: ReadonlyArray<{
2135
+ x: number;
2136
+ y: number;
2137
+ }>;
2138
+ current: {
2139
+ x: number;
2140
+ y: number;
2141
+ };
2142
+ shiftHeld: boolean;
2143
+ } | {
2144
+ kind: 'commands';
2145
+ commands: readonly DrawCommand[];
2146
+ /** Coordinate space the commands are authored in. Default `'world'`
2147
+ * — the layer wraps them in `viewToMat3(view)` so they track the
2148
+ * camera. `'screen'` emits them as-is (CSS pixels). */
2149
+ space?: 'world' | 'screen';
2150
+ } | {
2151
+ /**
2152
+ * Live insert-drag preview — dispatched by `insertAction` while the
2153
+ * user is dragging out a new shape. Pre-commit there is no scene node
2154
+ * to ghost via `previewIds()`/`previewPose()`, so insert paints its
2155
+ * preview through the dispatcher overlay layer instead.
2156
+ *
2157
+ * `shape` is the kit's built-in insert kind. `bounds` is the AABB of
2158
+ * the current drag (start/current normalized). `extras` is the
2159
+ * per-kind extras the action already collected — the overlay
2160
+ * renderer rebuilds the shape using the same path builders the
2161
+ * commit factory uses, so the preview matches the eventual node.
2162
+ *
2163
+ * `extras` is opaque (`unknown`) at the union level; the overlay
2164
+ * renderer narrows on `shape` and casts the field shape it expects.
2165
+ */
2166
+ kind: 'insertPreview';
2167
+ shape: 'rect' | 'ellipse' | 'line' | 'polygon' | 'star' | 'pencil';
2168
+ bounds: {
2169
+ x: number;
2170
+ y: number;
2171
+ width: number;
2172
+ height: number;
2173
+ };
2174
+ extras: unknown;
2175
+ /** World-space point to paint a small "anchor" dot at. Sells the
2176
+ * click point as the drag's anchor — particularly useful for
2177
+ * radial shapes (polygon/star) where no vertex sits on the
2178
+ * click point, and for any shape in center mode where the dot
2179
+ * marks the center the shape grows around. */
2180
+ anchorPoint?: {
2181
+ x: number;
2182
+ y: number;
2183
+ };
2184
+ };
2185
+ /** Handle returned from an `OngoingInvoker.start`. The dispatcher pumps
2186
+ * `onMove` on subsequent input events of the same gesture and calls
2187
+ * `onEnd` exactly once (with `'commit'` on natural completion or `'cancel'`
2188
+ * on pointercancel / blur / escape). */
2189
+ interface OngoingHandle {
2190
+ /**
2191
+ * Optional logical action kind — a stable, human-readable tag the
2192
+ * dispatcher exposes via `getActiveAction()` for chrome-visibility
2193
+ * rules and any other surface that wants to react to "what action
2194
+ * is currently in flight" without inspecting handles directly.
2195
+ *
2196
+ * Examples: `'marquee'`, `'lasso'`, `'move'`, `'resize'`, `'rotate'`,
2197
+ * `'pan'`, `'pinch'`.
2198
+ *
2199
+ * Distinct from the dispatcher's internal `gestureId` (`pointer-mouse`,
2200
+ * `key-held-Space`, etc.) which keys per-pointer state and is not
2201
+ * meaningful to consumers.
2202
+ *
2203
+ * When omitted, the action is "anonymous" — `getActiveAction().kind`
2204
+ * reports `null` even though a handle is in flight. This is fine for
2205
+ * actions that don't have visible chrome of their own.
2206
+ */
2207
+ kind?: string;
2208
+ onMove?(ctx: InvocationCtx): void;
2209
+ onEnd?(ctx: InvocationCtx, reason: 'commit' | 'cancel'): void;
2210
+ /**
2211
+ * Optional preview surface — dispatcher-side ghost overlay.
2212
+ *
2213
+ * An ongoing-action implementation may populate `previewIds()` +
2214
+ * `previewPose(id)` to expose its in-flight preview state for the
2215
+ * canvas's preview-ghost layer (`usePreviewGhostLayer`) to render on
2216
+ * top of the committed scene during the gesture.
2217
+ *
2218
+ * Returning `null` (or omitting the method entirely) means "no preview
2219
+ * this gesture" — the canvas will skip this handle as a source.
2220
+ *
2221
+ * Semantics mirror the tool-side `Tool.previewIds` / `Tool.previewPose`
2222
+ * pair: `previewIds()` enumerates the displaced node ids; `previewPose(id)`
2223
+ * returns the interim pose for one of those ids (shape opaque — the
2224
+ * canvas casts to its `TPose` parameter). The preview-ghost layer
2225
+ * merges all sources via first-non-null semantics, with tool-side
2226
+ * previews taking precedence over dispatcher-side (preserves
2227
+ * backwards-compat during the registry-unification migration).
2228
+ */
2229
+ previewIds?(): Iterable<string> | null;
2230
+ previewPose?(id: string): unknown | null;
2231
+ /**
2232
+ * When `false`, the preview-ghost layer paints the ghost AND the
2233
+ * source node stays visible at its committed pose. Defaults to
2234
+ * `true` (move/resize/rotate semantics: ghost replaces the source
2235
+ * during the gesture). Clone overrides to `false` so the original
2236
+ * stays put and the ghost appears at the drag target.
2237
+ */
2238
+ previewHidesSource?: boolean;
2239
+ /**
2240
+ * Optional per-id preview *data*. Falls back to the committed
2241
+ * `node.data` when null/absent. Use when the gesture mutates
2242
+ * `node.data` (e.g. anchor-edit on nodes that store the polygon on
2243
+ * `data.path`) rather than (or in addition to) the pose. The preview-
2244
+ * ghost layer assembles a synthetic node from `{ ...node, pose:
2245
+ * previewPose ?? node.pose, data: previewData ?? node.data }` before
2246
+ * calling the scene slot's `drawOne`.
2247
+ *
2248
+ * Sources compose first-non-null per axis: an action can emit only
2249
+ * `previewPose` (translation), only `previewData` (data-only edit),
2250
+ * or both (pose + data both change, e.g. anchor drag on a data.path
2251
+ * node where the bounds shift).
2252
+ */
2253
+ previewData?(id: string): unknown | null;
2254
+ /**
2255
+ * Optional chrome surface — dispatcher-side overlay layer.
2256
+ *
2257
+ * An ongoing-action implementation may populate `overlay()` to expose a
2258
+ * non-ghost visual (marquee rectangle, lasso polyline) for the canvas's
2259
+ * `useDispatcherOverlayLayer` to paint while the gesture is in flight.
2260
+ * Returning `null` (or omitting the method) means "no overlay this
2261
+ * gesture" — the canvas will skip this handle as a chrome source.
2262
+ *
2263
+ * Distinct from the `previewIds()`/`previewPose(id)` ghost surface,
2264
+ * which paints displaced scene-node silhouettes. Marquee and lasso
2265
+ * gestures don't displace any node, but still need on-screen feedback.
2266
+ */
2267
+ overlay?(): OngoingOverlay | null;
2268
+ }
2269
+ /** Fire-once invocation. Runs to completion synchronously (or fires off an
2270
+ * async side-effect; the registry doesn't wait). */
2271
+ interface ImmediateInvoker {
2272
+ timing: 'immediate';
2273
+ /** `params` carries the matched binding's opts.params. When
2274
+ * invoked via the legacy `Action.run` bridge or from the command palette
2275
+ * with no per-binding context, `params` is undefined; descriptors should
2276
+ * default to a sensible variant. */
2277
+ run(deps: ActionDeps, params?: Record<string, unknown>): void;
2278
+ }
2279
+ /** Phase-machine invocation. `start` opens the phase and returns the handle
2280
+ * the dispatcher pumps. */
2281
+ interface OngoingInvoker {
2282
+ timing: 'ongoing';
2283
+ start(ctx: InvocationCtx, opts?: BindingOpts): OngoingHandle;
2284
+ }
2285
+ /** Pluggable invocation strategy for an Action. Future variants
2286
+ * (`longPress`, `twoStage`, `modal`) extend this union without touching
2287
+ * the `Action` type. */
2288
+ type Invoker = ImmediateInvoker | OngoingInvoker;
2289
+
2290
+ /** Boolean op identifiers — five Pathfinder primaries plus Crop. */
2291
+ type BooleanOp = 'union' | 'intersect' | 'subtract' | 'exclude' | 'divide' | 'crop';
2292
+ /**
2293
+ * z-position descriptor for a path node. `parentId` is the direct parent
2294
+ * (or `null` for a top-level node); `index` is the position within that
2295
+ * parent's child order. Used by the optional `getZOrder` hook below to
2296
+ * reposition the result of a boolean op at the topmost source's slot.
2297
+ */
2298
+ /** @internal */
2299
+ interface BooleanZOrder {
2300
+ parentId: string | null;
2301
+ index: number;
2302
+ }
2303
+ /** Adapter the hook and the pure core both consume. */
2304
+ interface BooleansAdapter {
2305
+ getSelection(): NodeId[];
2306
+ getWorldPath(id: NodeId): Path | undefined;
2307
+ compareZ(a: NodeId, b: NodeId): number;
2308
+ /**
2309
+ * Mint a new node from a boolean-op result `Path`. `producedBy` names the
2310
+ * op that synthesized it — adapters that store provenance (e.g. for a
2311
+ * layer-panel icon) record it; others ignore the arg.
2312
+ */
2313
+ createPathNode(path: Path, producedBy: BooleanOp): {
2314
+ id: string;
2315
+ };
2316
+ /**
2317
+ * Optional: return the full object for an id, used by the delete ops so
2318
+ * their `invert` (an insert) can restore the complete object on undo.
2319
+ * If omitted, a `{ id }` stub is captured — undo will reinstate the id
2320
+ * but consumers reading other fields (path, fill, etc.) will see them as
2321
+ * undefined. Mirrors `DeleteAdapter.getNode`; should be provided whenever
2322
+ * undo over boolean ops is expected to be lossless.
2323
+ */
2324
+ getNode?(id: NodeId): {
2325
+ id: string;
2326
+ } | undefined | null;
2327
+ /**
2328
+ * Optional: return the parent + child-index of `id` so the result of a
2329
+ * boolean op can be placed in the topmost source's z-slot. Adapters that
2330
+ * also expose `getChildren`/`setChildOrder` (the `ReorderAdapter`
2331
+ * contract) will have the kit emit a `createMoveToIndexOp` after the
2332
+ * inserts. Adapters that omit this method get v1 behavior — the result
2333
+ * lands wherever the adapter's plain `insertNode` defaults to.
2334
+ */
2335
+ getZOrder?(id: NodeId): BooleanZOrder | undefined;
2336
+ applyOps?(ops: Op[], label?: string): void;
2337
+ setSelection?(ids: NodeId[]): void;
2338
+ insertNode?(node: {
2339
+ id: string;
2340
+ }): void;
2341
+ removeNode?(id: string): void;
2342
+ }
2343
+
2344
+ /** API returned by {@link useSelection}. */
2345
+ interface SelectionApi {
2346
+ /** Current selection. Re-renders trigger when this reference changes. */
2347
+ current: readonly NodeId[];
2348
+ /** Imperative read for use inside event callbacks (avoids stale closures). */
2349
+ get(): NodeId[];
2350
+ /** Replace selection. */
2351
+ set(ids: NodeId[]): void;
2352
+ /** Add id (multi-mode appends; single-mode replaces). */
2353
+ add(id: NodeId): void;
2354
+ /** Remove id from selection. */
2355
+ remove(id: NodeId): void;
2356
+ /** Toggle id in/out of selection. */
2357
+ toggle(id: NodeId): void;
2358
+ /** Clear selection. */
2359
+ clear(): void;
2360
+ /** True if id is selected. */
2361
+ contains(id: NodeId): boolean;
2362
+ /**
2363
+ * Apply a click to the selection per the configured mode/extend key.
2364
+ * - `single`: replaces selection with `[id]`, regardless of modifiers.
2365
+ * - `multi`: with the extend key held, toggles `id` in/out of the selection;
2366
+ * otherwise replaces with `[id]`.
2367
+ */
2368
+ applyClick(id: NodeId, modifiers: {
2369
+ shift: boolean;
2370
+ meta: boolean;
2371
+ ctrl: boolean;
2372
+ }): void;
2373
+ /** Pre-built methods for spreading into an adapter that needs them. */
2374
+ adapterMethods: {
2375
+ getSelection: () => NodeId[];
2376
+ setSelection: (ids: NodeId[]) => void;
2377
+ };
2378
+ }
2379
+
2380
+ /** Context handed to every content handler for one ingest event. */
2381
+ interface IngestCtx {
2382
+ /** World-space arrival point (drop / pointed imperative ingest); `null`
2383
+ * for paste and point-less calls — handlers pick their own policy
2384
+ * (the kit image handler centers on the viewport). */
2385
+ point: {
2386
+ x: number;
2387
+ y: number;
2388
+ } | null;
2389
+ /** Visible canvas area in world coordinates. */
2390
+ viewportWorldRect(): {
2391
+ x: number;
2392
+ y: number;
2393
+ width: number;
2394
+ height: number;
2395
+ };
2396
+ /** The kit insert dep — id/layer/undoable-op supplied; the canonical way
2397
+ * for a handler to mint a node (`insert.commit(bounds, { kind, ... })`). */
2398
+ insert: InsertDep;
2399
+ /** Raw op commit for handlers that build their own ops. */
2400
+ applyOps(ops: Op[], label?: string): void;
2401
+ scene: Scene<unknown, string, unknown>;
2402
+ selection: SelectionApi;
2403
+ /** Consumer file→src resolver (SceneCanvas `ingestion.resolveSrc`).
2404
+ * When absent, the kit image handler embeds as a `data:` URI. */
2405
+ resolveSrc?: (file: File) => Promise<string>;
2406
+ /** Kit SVG-handler options (SceneCanvas `ingestion.svg`) — e.g.
2407
+ * `{ unpack: unpackSvgFiles }` (from `@weasel-js/svg`) to parse SVG files
2408
+ * into scene nodes. */
2409
+ svg?: SvgIngestOptions;
2410
+ /** Clipboard-paste seam — present when the hosting `SceneCanvas` supplied
2411
+ * an adapter with `commitPaste`. `reviver` comes from
2412
+ * `SceneCanvasProps.ingestion.clipboard`. Absent ⇒ the kit weasel-JSON
2413
+ * handler declines inert (dwarn, nothing ingested) — its matched items
2414
+ * were already consumed at match time, so they do NOT fall through;
2415
+ * only match-level misses flow on to other handlers. */
2416
+ clipboard?: ClipboardIngestCtx;
2417
+ /** Set to `true` by the kit weasel-JSON handler when it successfully
2418
+ * pastes a payload in this event. The `ctx` object is shared across all
2419
+ * handlers in one `runIngest` call, and higher-priority handlers' `handle`
2420
+ * bodies run (synchronously) before lower ones — so `kit:svg`'s
2421
+ * `text/plain` SVG fallback reads this to decline the SVG flavor of a copy
2422
+ * whose canonical weasel-JSON flavor already ingested (avoids a
2423
+ * double-paste when both flavors ride one clipboard event). */
2424
+ consumedWeaselPayload?: boolean;
2425
+ /** Full action-deps bag, for consumer handlers that need more. */
2426
+ deps: ActionDeps;
2427
+ }
2428
+
2429
+ /**
2430
+ * @experimental
2431
+ * PointerContext — a tiny ambient context that publishes the world-space
2432
+ * position of the canvas pointer, refreshed on every `pointermove` over
2433
+ * the canvas. Cleared (set to `null`) on `pointerleave`.
2434
+ *
2435
+ * Why ref-based and not state-based: cursor moves fire dozens of times per
2436
+ * second; routing those through React state would re-render every consumer
2437
+ * in the tree. The context exposes a stable `pointerRef` whose `.current`
2438
+ * is mutated directly by the publisher, plus a thunk `getDropPoint()` that
2439
+ * reads it on demand. Consumers (e.g. `useClipboard`) pull via the thunk
2440
+ * inside their callbacks — no subscription, no re-render.
2441
+ *
2442
+ * `<SceneCanvas>` publishes automatically. `useClipboardOps` consumes when
2443
+ * the caller didn't pass an explicit `getDropPoint` option. Other future
2444
+ * hit-on-cursor consumers (drop-zone hover, context-menu anchor) can reuse
2445
+ * the same context.
2446
+ */
2447
+
2448
+ /** @experimental World-space pointer position, or `null` when the pointer
2449
+ * isn't over the publishing canvas. */
2450
+ type PointerWorldPos = {
2451
+ worldX: number;
2452
+ worldY: number;
2453
+ } | null;
2454
+ /** @experimental */
2455
+ interface PointerContextValue {
2456
+ /** Live ref — mutate to publish, read for the latest snapshot. The
2457
+ * identity is stable for the lifetime of the provider. */
2458
+ readonly pointerRef: MutableRefObject<PointerWorldPos>;
2459
+ /** Convenience thunk equivalent to `() => pointerRef.current`. Stable
2460
+ * identity for the lifetime of the provider; safe to pass to hooks. */
2461
+ readonly getDropPoint: () => PointerWorldPos;
2462
+ }
2463
+
2464
+ interface ActiveToolContextValue {
2465
+ active: string;
2466
+ hotkeyStack: string[];
2467
+ setActive(id: string): void;
2468
+ pushHotkey(id: string): void;
2469
+ popHotkey(): void;
2470
+ }
2471
+
2472
+ /**
2473
+ * `enterTextEditAction` — immediate Action descriptor for entering in-place
2474
+ * text editing on a selected text node.
2475
+ *
2476
+ * ## Status: REAL
2477
+ *
2478
+ * Fires via `useTextTool.bindings` when the user clicks on a
2479
+ * selected text node. Calls `deps.textEdit.startEdit(id)` to activate the
2480
+ * contenteditable overlay managed by `useTextEdit` / `useSceneTextEdit`.
2481
+ *
2482
+ * ## No defaultBinding / defaultBinding
2483
+ *
2484
+ * This action has no ambient key or gesture binding — it fires ONLY via
2485
+ * `useTextTool`'s `Tool.bindings` entry:
2486
+ *
2487
+ * ```ts
2488
+ * bindings: [
2489
+ * { spec: { kind: 'click', target: 'selected-body' }, actionId: 'enterTextEdit' },
2490
+ * ]
2491
+ * ```
2492
+ *
2493
+ * Keeping it binding-free avoids ambient double-fire and scopes the action to
2494
+ * the text tool context where `classifyTarget` is already wired.
2495
+ *
2496
+ * ## Self-guard: only act on text nodes
2497
+ *
2498
+ * The `'selected-body'` target yields a match for any selected node kind. To
2499
+ * avoid entering text-edit mode when the text tool happens to have a non-text
2500
+ * node selected, the action self-guards via an optional `isTextNode` predicate
2501
+ * on `TextEditDep`:
2502
+ *
2503
+ * - When `isTextNode` is absent: action fires unconditionally (the binding
2504
+ * spec is the real gate — consumers should only bind this action from the
2505
+ * text tool).
2506
+ * - When `isTextNode(id)` returns `false`: action is a no-op for that node.
2507
+ *
2508
+ * ### Pre-filtering at dispatch time
2509
+ *
2510
+ * `classifyTarget` now surfaces node kind, so a binding can pre-filter instead
2511
+ * of relying on the self-guard:
2512
+ *
2513
+ * ```ts
2514
+ * { spec: { kind: 'click', target: 'kind:text:selected' }, actionId: 'enterTextEdit' }
2515
+ * ```
2516
+ *
2517
+ * That reads the *routing trait's* kind, so it matches whatever names the
2518
+ * consumer registered in `<SceneCanvas routing>` — `'text'` under the kit's
2519
+ * inferred default. `isTextNode` stays on `TextEditDep` because it also covers
2520
+ * consumers who bind the broader `'selected-body'` target, and because it is
2521
+ * the only guard for a consumer who opted out of routing entirely.
2522
+ *
2523
+ * ## Migration plan for useTextTool
2524
+ *
2525
+ * When wiring `useTextTool` to `Tool.bindings`:
2526
+ *
2527
+ * 1. Add to `useTextTool`'s `bindings`:
2528
+ * ```ts
2529
+ * { spec: { kind: 'click', target: 'selected-body' }, actionId: 'enterTextEdit' }
2530
+ * ```
2531
+ * 2. Register a `textEdit` dep sourced from the `useTextEdit` / `useSceneTextEdit`
2532
+ * return value, plus an `isTextNode` predicate that checks `data.kind === 'text'`
2533
+ * (or however the consumer identifies text nodes).
2534
+ * 3. The existing `hitExisting` gate in `useTextTool`'s click route becomes
2535
+ * redundant — remove it in the same pass.
2536
+ */
2537
+
2538
+ /**
2539
+ * Dep for `enterTextEditAction`.
2540
+ *
2541
+ * Wrap the return value of `useTextEdit` / `useSceneTextEdit` to source this
2542
+ * dep. The `isTextNode` predicate is optional — when absent the action fires
2543
+ * unconditionally (the binding spec acts as the gate).
2544
+ *
2545
+ * @example
2546
+ * ```ts
2547
+ * const textEdit = useSceneTextEdit({ scene, container });
2548
+ * useDepSource('textEdit', () => ({
2549
+ * startEdit: textEdit.startEdit,
2550
+ * isTextNode: (id) => scene.get(id as NodeId)?.data?.kind === 'text',
2551
+ * }));
2552
+ * ```
2553
+ */
2554
+ interface TextEditDep {
2555
+ /**
2556
+ * Begin editing the node with `id`. Activates the contenteditable overlay
2557
+ * managed by `useTextEdit` / `useSceneTextEdit`.
2558
+ */
2559
+ startEdit(id: string, opts?: {
2560
+ caret?: number | 'all';
2561
+ }): void;
2562
+ /**
2563
+ * Optional predicate: returns `true` when the node with `id` is a text node.
2564
+ * When absent the action fires on any selected node (binding spec is the gate).
2565
+ * When present and returning `false`, the invocation is a no-op.
2566
+ */
2567
+ isTextNode?(id: string): boolean;
2568
+ }
2569
+
2570
+ /**
2571
+ * Consumer-supplied commit for the Slice action. `commit` receives the finite
2572
+ * slice segment (world coords); the consumer scans the scene, splits crossed
2573
+ * paths via `splitPathByLine`, and applies the result as one undoable batch.
2574
+ */
2575
+ interface SliceDep {
2576
+ commit(a: Point2, b: Point2): void;
2577
+ }
2578
+
2579
+ /**
2580
+ * Bridges arbitrary `TPose` shapes into the resize hook's bounds-driven math.
2581
+ * The hook reads bounds via `getBounds`, runs anchor-relative math on those
2582
+ * bounds, then asks `remapBounds` to project the result back into TPose.
2583
+ *
2584
+ * `remapBounds(pose, src, dst)` is a single operation that subsumes both
2585
+ * "set my own AABB to dst" (single-leaf resize) and "scale me as a leaf
2586
+ * inside parent's src→dst rect" (group resize) — they're the same affine
2587
+ * map. For rect-shaped poses the default geometry interprets the pose as
2588
+ * its own bounds; for Path or polygon poses the consumer supplies a
2589
+ * projection that knows how to read and rewrite the underlying geometry.
2590
+ */
2591
+ interface PoseProjection<TPose> {
2592
+ getBounds(pose: TPose): ResizePose;
2593
+ remapBounds(pose: TPose, src: ResizePose, dst: ResizePose): TPose;
2594
+ /** Translate the pose by (dx, dy). Optional — when omitted, callers fall
2595
+ * back to a translation derived from `remapBounds` (origin shifted, no
2596
+ * scale). Path-shaped poses should provide this for performance. */
2597
+ translate?(pose: TPose, dx: number, dy: number): TPose;
2598
+ /** True iff any portion of the pose's geometry intersects `rect`. Optional
2599
+ * — when omitted, area-select and similar callers test against `getBounds`
2600
+ * AABB (looser, but correct for axis-aligned rect poses). */
2601
+ intersectsRect?(pose: TPose, rect: ResizePose): boolean;
2602
+ /** Interpolate between two poses. Optional — animation helpers fall back to
2603
+ * rect-shape lerp when omitted (which fails for non-rect poses). */
2604
+ lerp?(a: TPose, b: TPose, t: number): TPose;
2605
+ /** Read the pose's rotation in radians. Pivot is the AABB center
2606
+ * (`getBounds(pose)` center). Default 0 when omitted — descriptor
2607
+ * declares "this pose has no rotation." When supplied and non-zero,
2608
+ * `useResize` projects the drag delta into the leaf's local frame,
2609
+ * runs anchor math there, and translates the resulting pose so the
2610
+ * diagonally opposite world-space corner is pinned. */
2611
+ getRotation?(pose: TPose): number;
2612
+ /** True iff this pose shape can carry a rotation. Consulted by the
2613
+ * rotation affordance to decide whether to render the rotate cursor /
2614
+ * drag-band over a selection. When omitted, the kit assumes `true` for
2615
+ * back-compat — descriptors whose poses lack `x/y/width/height/rotation`
2616
+ * fields (e.g. polygon Paths) should return `false` so the affordance
2617
+ * hides instead of exposing a non-functional rotate cursor. */
2618
+ supportsRotation?(pose: TPose): boolean;
2619
+ }
2620
+
2621
+ /**
2622
+ * 2D affine transforms in canvas/DOMMatrix order: [a, b, c, d, e, f].
2623
+ * x' = a·x + c·y + e
2624
+ * y' = b·x + d·y + f
2625
+ * Represented as a 6-element number[] (f64). The affine tier of the kernel.
2626
+ *
2627
+ * Convention alignment: the renderer already has a `Mat3` in
2628
+ * `src/renderer/math/mat3.ts`. That one is a 9-element column-major
2629
+ * `Float32Array` (a full 3×3) shaped for `uniformMatrix3fv` — a deliberately
2630
+ * different *representation* for the WebGL upload path. Its *logical element
2631
+ * order* is identical to ours: `create(a, b, c, d, tx, ty)` maps
2632
+ * `x' = a·x + c·y + tx`, `y' = b·x + d·y + ty` (canvas/DOMMatrix a,b,c,d,e,f).
2633
+ * We keep the pure 6-tuple f64 form here (the kernel form); the 9-element f32
2634
+ * form stays a render-layer concern. No second logical convention is created.
2635
+ */
2636
+ type Mat3 = number[];
2637
+
2638
+ /** Optional consumer seam: given a node and the affine `m` that a pose-transform
2639
+ * action applied to the node's POSE, return updated `data` with the node's
2640
+ * data-held geometry transformed by `m`, or `null` if this node has no
2641
+ * data-held geometry (the kit leaves `data` alone). */
2642
+ interface GeometryProjection {
2643
+ transform(node: {
2644
+ id?: string;
2645
+ data: unknown;
2646
+ pose: unknown;
2647
+ }, m: Mat3): unknown | null;
2648
+ }
2649
+
2650
+ /** Minimal view API the action layer consumes. May be refined later. */
2651
+ interface ViewApi {
2652
+ get(): View;
2653
+ set(v: View): void;
2654
+ /** Optional recenter callback. When wired, `viewportZoomAction`'s
2655
+ * `reset` branch (Cmd-0) calls this instead of resetting to identity —
2656
+ * letting consumers re-fit the page (or other reference bounds) into
2657
+ * the workspace. Receives no args; the consumer reads its own bounds
2658
+ * + host dims and dispatches `setView(...)`. */
2659
+ recenter?(): void;
2660
+ /** Optional canvas-local host dimensions (CSS px). When wired,
2661
+ * `viewportZoomAction`'s keyboard branches (Cmd+= / Cmd+-) anchor at the
2662
+ * host center instead of the top-left origin. Null when the host isn't
2663
+ * measurable (unmounted). */
2664
+ hostSize?(): {
2665
+ width: number;
2666
+ height: number;
2667
+ } | null;
2668
+ }
2669
+ /**
2670
+ * Adapter dep for `areaSelectAction`.
2671
+ *
2672
+ * Provided by `<SceneCanvas>` / `<StandardActionsRegistrar>` via AABB
2673
+ * overlap over scene nodes. Consumers with custom hit-testing override this
2674
+ * dep entry in their own registrar.
2675
+ */
2676
+ /**
2677
+ * Topmost-node-at-world-point dep, consumed by `moveAction` for
2678
+ * reparent-on-drop and available to any action that needs a single-best
2679
+ * pick. Mirrors the same hit-test plumbing `<SceneCanvas>` feeds to the
2680
+ * tool dispatcher; consumers with custom hit-testing override here.
2681
+ *
2682
+ * `exclude` is iterated once per call and treated as a set membership
2683
+ * test — the dep walks hits front-to-back and returns the first id not
2684
+ * in the exclude set. Pass moving-node roots + their descendants when
2685
+ * the caller wants to ignore the nodes it's manipulating.
2686
+ */
2687
+ type NodeAtPointDep = (point: {
2688
+ x: number;
2689
+ y: number;
2690
+ }, exclude?: Iterable<NodeId>) => NodeId | null;
2691
+ interface AreaSelectDep {
2692
+ /** Return ids of all scene nodes whose AABB overlaps `bounds`. */
2693
+ hitTestArea(bounds: {
2694
+ x: number;
2695
+ y: number;
2696
+ width: number;
2697
+ height: number;
2698
+ }): NodeId[];
2699
+ /** Return the current selection id list. */
2700
+ getSelection(): NodeId[];
2701
+ /** Replace the current selection. */
2702
+ setSelection(ids: NodeId[]): void;
2703
+ }
2704
+ /**
2705
+ * Adapter dep for `editAnchorsAction`.
2706
+ *
2707
+ * Provides narrow read/write access to the editable polygon for a single
2708
+ * node. Consumers register this dep so anchor-edit actions can read/write
2709
+ * the polygon WITHOUT knowing whether it lives directly on the node's
2710
+ * pose (`pose.kind === 'polygon'`) or on `node.data.path` (the kit's
2711
+ * built-in pen-tool default, also WeaselDraw's shape).
2712
+ *
2713
+ * Note on live previews: in-flight edit state is surfaced through the
2714
+ * dispatcher's standard `OngoingHandle.previewIds/previewPose/previewData`
2715
+ * triple (not this dep), so chrome and preview-ghost stay in lock-step
2716
+ * via one source of truth.
2717
+ */
2718
+ interface EditAnchorsDep {
2719
+ /** Id of the node currently being edited. Empty string means no node is
2720
+ * currently in edit mode — the chrome and gesture both opt out. */
2721
+ editingId: string;
2722
+ /** Enter/exit edit mode for a specific node. Pass `null` (or an empty
2723
+ * string) to exit. `enterPathEditAction` and `exitPathEditAction` call
2724
+ * this; consumers can call it directly to drive edit mode programmatically. */
2725
+ setEditingId(id: string | null): void;
2726
+ /** Returns the COMMITTED editable polygon in world coordinates, or
2727
+ * null if this node has no editable polygon. Does NOT consult in-
2728
+ * flight previews — callers that need live state read the dispatcher's
2729
+ * in-flight handles. */
2730
+ getEditablePath(id: string): unknown;
2731
+ /** Returns where the polygon is stored — `'pose'` when `node.pose`
2732
+ * IS the polygon, `'data'` when it lives on `node.data.path` with a
2733
+ * rect pose, or `null` when the node has no editable polygon. The
2734
+ * action uses this to know which preview-ghost axis to populate
2735
+ * (`previewPose` only / `previewData` + `previewPose` for data.path). */
2736
+ getStorageKind(id: string): 'pose' | 'data' | null;
2737
+ /** Returns the node's raw `pose` and `data` so storage-aware actions
2738
+ * can capture origin state at gesture-start and synthesize a matching
2739
+ * `previewPose` / `previewData` during `onMove`. Used by
2740
+ * `editAnchorsAction` for the data.path branch (rect pose + data
2741
+ * carrying extra fields like fill / stroke that must be preserved
2742
+ * through the preview). Returns null when the node is gone. */
2743
+ getNodeShape(id: string): {
2744
+ pose: unknown;
2745
+ data: unknown;
2746
+ } | null;
2747
+ /** Commit `worldPath` as the new value for `id`. Implementation routes
2748
+ * to setPose (when pose IS the polygon) or batched setPose+update
2749
+ * (when the polygon lives on data.path). Records one history entry
2750
+ * labelled `label`. */
2751
+ applyEdit(id: string, worldPath: unknown, label: string): void;
2752
+ /**
2753
+ * Anchors currently selected within the edited path, as **flat anchor
2754
+ * indices** — the same numbering `enumerateAnchors` produces and the
2755
+ * `anchor:N` affordance kinds carry.
2756
+ *
2757
+ * Selection is transient UI state, deliberately not part of the scene:
2758
+ * it is cleared whenever `editingId` changes, and any edit that
2759
+ * renumbers anchors (insert, delete) is responsible for leaving it
2760
+ * coherent. Empty means "no anchor selected" — the keyboard actions
2761
+ * (nudge, delete) no-op rather than acting on all anchors, matching
2762
+ * Illustrator.
2763
+ */
2764
+ selectedAnchors: ReadonlySet<number>;
2765
+ /** Replace the anchor selection. Pass an empty iterable to clear. */
2766
+ setSelectedAnchors(next: Iterable<number>): void;
2767
+ /**
2768
+ * In-flight anchor-marquee rect in world coords, or null when no
2769
+ * marquee drag is active. Written by `marqueeAnchorsAction` and read by
2770
+ * the path-editing overlay — the same "ongoing action owns the preview,
2771
+ * chrome just draws it" split the move/resize ghosts use.
2772
+ */
2773
+ marquee: {
2774
+ x: number;
2775
+ y: number;
2776
+ width: number;
2777
+ height: number;
2778
+ } | null;
2779
+ /** Set or clear the in-flight marquee rect. */
2780
+ setMarquee(rect: {
2781
+ x: number;
2782
+ y: number;
2783
+ width: number;
2784
+ height: number;
2785
+ } | null): void;
2786
+ }
2787
+ /**
2788
+ * Adapter dep for `lassoSelectAction`.
2789
+ *
2790
+ * Provides polygon-lasso hit-testing + selection read/write.
2791
+ * Consumers that don't implement `hitTestLasso` can omit it; the action
2792
+ * falls back to a bounding-box AABB test via `hitTestArea`.
2793
+ */
2794
+ interface LassoSelectDep {
2795
+ /**
2796
+ * Hit-test against a closed polygon (vertex order CW or CCW; last→first
2797
+ * closing edge is implicit). Returns matching node ids.
2798
+ * Optional — when absent, `lassoSelectAction` falls back to AABB via
2799
+ * `hitTestArea`.
2800
+ */
2801
+ hitTestLasso?(polygon: ReadonlyArray<{
2802
+ x: number;
2803
+ y: number;
2804
+ }>, mode: 'centers' | 'intersect' | 'enclosed'): string[];
2805
+ /** Return ids of nodes whose AABB overlaps the given rect (fallback). */
2806
+ hitTestArea(bounds: {
2807
+ x: number;
2808
+ y: number;
2809
+ width: number;
2810
+ height: number;
2811
+ }): string[];
2812
+ /** Return the current selection id list. */
2813
+ getSelection(): string[];
2814
+ /** Replace the current selection. */
2815
+ setSelection(ids: string[]): void;
2816
+ }
2817
+ /**
2818
+ * Options for the kit `image/svg+xml` content handler, threaded from
2819
+ * SceneCanvas's `ingestion={{ svg }}` prop.
2820
+ */
2821
+ interface SvgIngestOptions {
2822
+ /** Parse dropped/pasted/picked SVG files into native scene nodes (path /
2823
+ * text leaves under containers mirroring the source `<g>` structure)
2824
+ * instead of the default single embedded-image node.
2825
+ *
2826
+ * Pass `unpackSvgFiles` from `@weasel-js/svg`:
2827
+ *
2828
+ * ```ts
2829
+ * import { unpackSvgFiles } from '@weasel-js/svg';
2830
+ * <SceneCanvas ingestion={{ svg: { unpack: unpackSvgFiles } }} />
2831
+ * ```
2832
+ *
2833
+ * It is injected rather than flagged on with `true` because the SVG parser
2834
+ * lives in `@weasel-js/svg`, which depends on this package — core importing
2835
+ * it back would make the two mutually dependent and unpublishable
2836
+ * separately. Passing the function keeps the parser out of core's bundle
2837
+ * for consumers who never unpack. */
2838
+ unpack?: SvgUnpacker;
2839
+ }
2840
+ /** Parses SVG files and inserts the resulting nodes into `ctx.scene`, as one
2841
+ * `applyOps` batch per file. Implemented by `unpackSvgFiles` in
2842
+ * `@weasel-js/svg`; see {@link SvgIngestOptions.unpack}. */
2843
+ type SvgUnpacker = (files: File[], ctx: IngestCtx) => Promise<void>;
2844
+ /**
2845
+ * Clipboard-paste seam consumed by the kit weasel-JSON content handler
2846
+ * (`IngestCtx.clipboard`). Built by `<SceneCanvas>` from its own synthesized
2847
+ * adapter + the `ingestion.clipboard` prop; absent when the consumer set
2848
+ * `ingestion.clipboard.enabled === false` or the adapter lacks `commitPaste`.
2849
+ * Absence makes the handler decline inert (dwarn, nothing ingested) — its
2850
+ * matched items were already consumed at match time and do not fall through
2851
+ * to other handlers.
2852
+ */
2853
+ interface ClipboardIngestCtx {
2854
+ /** The hosting canvas's adapter — `commitPaste` materializes the pasted
2855
+ * nodes (fresh ids, offset applied); insertion still goes through ops. */
2856
+ adapter: InsertAdapter<{
2857
+ id: string;
2858
+ }>;
2859
+ /** JSON reviver for the weasel wire payload (typed arrays etc.) — from
2860
+ * `SceneCanvasProps.ingestion.clipboard.reviver`. */
2861
+ reviver?: (key: string, value: unknown) => unknown;
2862
+ }
2863
+ /**
2864
+ * Dep for the `ingest` action (external-content ingestion).
2865
+ * Sourced from `<SceneCanvas>` / `<StandardActionsRegistrar>` via
2866
+ * `useIngestionDepSource` — canvas rect + current view.
2867
+ */
2868
+ interface IngestionDep {
2869
+ /** Visible canvas area in world coordinates. */
2870
+ viewportWorldRect(): {
2871
+ x: number;
2872
+ y: number;
2873
+ width: number;
2874
+ height: number;
2875
+ };
2876
+ /** Consumer file→src resolver (from SceneCanvas's `ingestion` prop).
2877
+ * Live accessor — read it at use time. Destructuring (or copying the
2878
+ * property early) snapshots the current value and won't track later
2879
+ * prop changes across an `await`. */
2880
+ resolveSrc?: (file: File) => Promise<string>;
2881
+ /** Kit SVG-handler options (from SceneCanvas's `ingestion` prop).
2882
+ * Live accessor, same caveat as `resolveSrc`. */
2883
+ svg?: SvgIngestOptions;
2884
+ /** Clipboard-paste seam for the kit weasel-JSON handler.
2885
+ * Live accessor, same caveat as `resolveSrc`. */
2886
+ clipboard?: ClipboardIngestCtx;
2887
+ }
2888
+ /**
2889
+ * Per-kind extra geometry passed to `InsertDep.commit`.
2890
+ *
2891
+ * Built-in tools populate a typed variant so the kit's default factory can
2892
+ * render the true tool params (line endpoints, polygon side count, star
2893
+ * geometry, pencil sample list). Consumer-defined tools may pass any
2894
+ * `{ kind: string; ... }` payload; the kit's factory falls back to AABB
2895
+ * inscription for unknown kinds.
2896
+ *
2897
+ * `bounds` is still passed alongside as a useful AABB pose hint — factories
2898
+ * may use it as the node's pose even when richer geometry is available.
2899
+ */
2900
+ type InsertExtras = {
2901
+ kind: 'rect';
2902
+ } | {
2903
+ kind: 'ellipse';
2904
+ } | {
2905
+ kind: 'line';
2906
+ a: {
2907
+ x: number;
2908
+ y: number;
2909
+ };
2910
+ b: {
2911
+ x: number;
2912
+ y: number;
2913
+ };
2914
+ } | {
2915
+ kind: 'polygon';
2916
+ sides: number;
2917
+ rotation: number;
2918
+ center?: {
2919
+ x: number;
2920
+ y: number;
2921
+ };
2922
+ radius?: number;
2923
+ } | {
2924
+ kind: 'star';
2925
+ points: number;
2926
+ innerRadiusRatio: number;
2927
+ rotation: number;
2928
+ center?: {
2929
+ x: number;
2930
+ y: number;
2931
+ };
2932
+ outerRadius?: number;
2933
+ } | {
2934
+ kind: 'pencil';
2935
+ samples: ReadonlyArray<DragSample>;
2936
+ } | {
2937
+ kind: 'text';
2938
+ text?: string;
2939
+ } | {
2940
+ kind: string;
2941
+ [extra: string]: unknown;
2942
+ };
2943
+ /**
2944
+ * World-space point snapping — grid, guides, or any consumer rule.
2945
+ *
2946
+ * Sourced by `<SceneCanvas>` from its `toolOptions.snapPoint`. Actions apply
2947
+ * it to the coords they ingest so the live preview and the committed
2948
+ * geometry agree; `insertAction` snaps the drag's start and current point.
2949
+ *
2950
+ * Optional: when the dep is absent, actions treat it as identity.
2951
+ */
2952
+ interface SnapDep {
2953
+ /** Snap a world-space point. Return `p` unchanged to opt out. */
2954
+ point(p: {
2955
+ x: number;
2956
+ y: number;
2957
+ }): {
2958
+ x: number;
2959
+ y: number;
2960
+ };
2961
+ }
2962
+ /**
2963
+ * Adapter dep for `insertAction`.
2964
+ *
2965
+ * Provided by `<SceneCanvas>` / `<StandardActionsRegistrar>`. The `extras`
2966
+ * carry the active tool's kind + per-kind geometry. Callers
2967
+ * that need typed data must supply a richer `insert` dep.
2968
+ */
2969
+ interface InsertDep {
2970
+ /**
2971
+ * Materialise a new node from the given drag-rect bounds and typed
2972
+ * per-kind extras. Returns the new node's id, or `null` if the consumer
2973
+ * rejected the insert (e.g. sub-threshold bounds, unknown kind).
2974
+ */
2975
+ commit(bounds: {
2976
+ x: number;
2977
+ y: number;
2978
+ width: number;
2979
+ height: number;
2980
+ }, extras: InsertExtras): NodeId | null;
2981
+ }
2982
+ /**
2983
+ * Adapter dep for `resizeAction`.
2984
+ *
2985
+ * Carries the four behavior-shaping options the legacy `useResize` hook
2986
+ * exposed through `UseResizeOptions`: bounds-frame behaviors (e.g.
2987
+ * `lockAspectWithModifier`), world-space anchor-point snap behaviors (e.g.
2988
+ * `pointSnapToGrid`), group-expansion (`expandIds`), and pose↔bounds
2989
+ * projection (`geometry`).
2990
+ *
2991
+ * Optional in `DepSchema`: when absent, `resizeAction` falls back to
2992
+ * identity defaults (no behaviors, identity expandIds, `RECT_POSE_DESCRIPTOR`
2993
+ * geometry). Consumers wire the dep via `useDepSource('resizePolicy', ...)`
2994
+ * from any descendant of `<DepRegistryProvider>` / `<SceneCanvas>`.
2995
+ *
2996
+ * The generic is erased to `unknown` at the schema entry; consumers cast at
2997
+ * the call site (mirrors the `scene` entry's convention).
2998
+ */
2999
+ interface ResizePolicy<TPose> {
3000
+ /** Bounds-frame constraints. Constrained to `TPose extends ResizePose` since
3001
+ * constraints read/write `{x,y,width,height}`. For non-rect TPose pass `[]`. */
3002
+ constraints: TPose extends ResizePose ? BoundsConstraint<TPose>[] : never[];
3003
+ /** World-space anchor-point snap behaviors. Same TPose constraint as
3004
+ * `constraints`. */
3005
+ pointSnap: TPose extends ResizePose ? PointSnapBehavior<TPose>[] : never[];
3006
+ /** Group-expansion at gesture start. Identity (`ids => ids`) when group
3007
+ * resize isn't wanted. */
3008
+ expandIds: (ids: string[]) => string[];
3009
+ /** Projection from `TPose` to bounds and back. Use `RECT_POSE_DESCRIPTOR`
3010
+ * for plain rect poses. */
3011
+ projection: PoseProjection<TPose>;
3012
+ }
3013
+ /**
3014
+ * Layout-strategy lookup by container id, consumed by `moveAction` to run
3015
+ * the drag-time reflow pass. Sourced by `<SceneCanvas>` from its `layouts`
3016
+ * prop. Optional: `getLayout` returns null for any container when no layout
3017
+ * is configured, so the reflow pass is a no-op then.
3018
+ */
3019
+ interface LayoutDep {
3020
+ getLayout(containerId: string): LayoutStrategy<unknown> | null;
3021
+ }
3022
+ interface DepSchema {
3023
+ /** Kit selection state — ids of currently selected nodes. */
3024
+ selection: SelectionApi;
3025
+ /** Current viewport — camera position + scale. */
3026
+ view: ViewApi;
3027
+ /**
3028
+ * Scene tree — structural reads + undoable mutations.
3029
+ *
3030
+ * The entry uses the fully-erased form `Scene<unknown, string, unknown>`
3031
+ * because `DepSchema` must be concrete. Actions that need a typed scene
3032
+ * should cast: `deps.scene as Scene<MyData, MyLayer, MyPose>`.
3033
+ */
3034
+ scene: Scene<unknown, string, unknown>;
3035
+ /** Undo/redo history bound to the current scene. */
3036
+ history: History;
3037
+ /**
3038
+ * Canvas pointer position in world space.
3039
+ *
3040
+ * Exposes `pointerRef` (mutable live ref) and `getDropPoint()` thunk.
3041
+ * Marked `@experimental` in the source.
3042
+ */
3043
+ pointer: PointerContextValue;
3044
+ /** Currently active tool id + hotkey-hold stack. */
3045
+ activeTool: ActiveToolContextValue;
3046
+ /**
3047
+ * Area-select dep — AABB hit-test + selection read/write.
3048
+ *
3049
+ * Sourced from `<SceneCanvas>` via AABB overlap over all scene
3050
+ * nodes. Override per-consumer for custom hit-testing (e.g. contain-mode,
3051
+ * lock-aware filtering).
3052
+ */
3053
+ areaSelect: AreaSelectDep;
3054
+ /**
3055
+ * Topmost node at a world-space point. Sourced by `<SceneCanvas>` from
3056
+ * the same picker that feeds the tool dispatcher's `getNodeAtPoint`.
3057
+ * Optional: actions that read this (e.g. `moveAction` reparent-on-drop)
3058
+ * fall back to a no-op when the dep isn't registered.
3059
+ */
3060
+ nodeAtPoint?: NodeAtPointDep;
3061
+ /**
3062
+ * Insert dep — node factory for drag-to-insert.
3063
+ *
3064
+ * Sourced from `<SceneCanvas>`. The `kind` param comes from
3065
+ * the active binding's `opts.params.kind`. Override per-consumer to
3066
+ * provide a typed node factory (e.g. with custom data payloads).
3067
+ */
3068
+ insert: InsertDep;
3069
+ /**
3070
+ * Snap dep — world-space point snapping (grid / guides).
3071
+ *
3072
+ * Sourced by `<SceneCanvas>` from `toolOptions.snapPoint`. Optional:
3073
+ * absent means no snapping (identity).
3074
+ */
3075
+ snap?: SnapDep;
3076
+ /**
3077
+ * Lasso-select dep — polygon hit-test + selection read/write.
3078
+ *
3079
+ * Sourced from `<SceneCanvas>` / `<StandardActionsRegistrar>`.
3080
+ * Falls back to AABB hit-test when `hitTestLasso` is absent.
3081
+ */
3082
+ lassoSelect: LassoSelectDep;
3083
+ /**
3084
+ * Edit-anchors dep — narrow read/write of one polygon's path pose.
3085
+ *
3086
+ * Sourced from consumer. Wraps `getPose`/`setPose`/`applyOps`
3087
+ * for the currently-being-edited polygon node.
3088
+ *
3089
+ * The `editAnchorsAction` requires this dep to be registered when anchor
3090
+ * editing is active. If absent, `start` returns an empty handle (no-op).
3091
+ */
3092
+ editAnchors: EditAnchorsDep;
3093
+ /**
3094
+ * Text-edit dep — activates the in-place text editing overlay.
3095
+ *
3096
+ * Sourced from consumer via `useTextEdit` / `useSceneTextEdit`.
3097
+ * The `enterTextEditAction` requires this dep to be registered by the text
3098
+ * tool when text editing is available.
3099
+ *
3100
+ * The optional `isTextNode` predicate guards against entering edit mode on
3101
+ * non-text nodes. A binding can pre-filter instead with a
3102
+ * `target: 'kind:text:selected'` spec; the guard remains for consumers who
3103
+ * bind the broader `'selected-body'` target or opted out of routing.
3104
+ */
3105
+ textEdit: TextEditDep;
3106
+ /**
3107
+ * Resize-policy dep — bounds constraints, point-snap behaviors,
3108
+ * group expansion, and pose↔bounds projection for `resizeAction`.
3109
+ *
3110
+ * Optional: when omitted, `resizeAction` falls back to identity defaults
3111
+ * (no constraints, no snap, identity expandIds, `RECT_POSE_DESCRIPTOR`).
3112
+ * Consumers wire via `useDepSource('resizePolicy', ...)` or the
3113
+ * `useResizePolicy` helper.
3114
+ */
3115
+ resizePolicy?: ResizePolicy<unknown>;
3116
+ /**
3117
+ * Booleans adapter — read selection ids, fetch world-space `Path`s,
3118
+ * compare z-order, and mint result nodes for Pathfinder ops.
3119
+ *
3120
+ * Consumers wire via `useBooleansAdapter(adapter)` (a thin wrapper
3121
+ * around `useDepSource('booleansAdapter', ...)`). The descriptor's
3122
+ * `enabled` predicate reads `deps.selection` for the count check; the
3123
+ * invoker reads `deps.booleansAdapter` to execute the op.
3124
+ */
3125
+ booleansAdapter?: BooleansAdapter;
3126
+ /**
3127
+ * Gesture dispatcher control surface — exposes `cancelAll(reason)` so
3128
+ * actions that need to abort an in-flight handle (Escape cancels a
3129
+ * drag, etc.) can do so. Sourced by `<SceneCanvas>` from the
3130
+ * dispatcher instance it already owns.
3131
+ */
3132
+ dispatcher?: {
3133
+ cancelAll(reason: 'commit' | 'cancel'): void;
3134
+ };
3135
+ /**
3136
+ * Layout-strategy lookup. Sourced by `<SceneCanvas>` from `layouts`.
3137
+ * Optional: absent (or all-null) → `moveAction` skips reflow.
3138
+ */
3139
+ layout?: LayoutDep;
3140
+ /**
3141
+ * Slice dep — consumer-supplied commit for the Slice action.
3142
+ *
3143
+ * Receives the finite slice segment in world coordinates; the consumer
3144
+ * scans the scene, splits crossed paths via `splitPathByLine`, and
3145
+ * applies the result as one undoable batch.
3146
+ *
3147
+ * Optional: when absent, `sliceAction` is a no-op.
3148
+ */
3149
+ slice?: SliceDep;
3150
+ /**
3151
+ * Optional consumer commit hook. When present, `moveAction` (and other
3152
+ * default actions) submit their committed ops through it instead of
3153
+ * `scene.applyBatch`, so apps with their own history integration
3154
+ * (checkpoint + push entry) capture the gesture as one undo entry.
3155
+ * When absent, commits fall back to `scene.applyBatch`.
3156
+ */
3157
+ applyOps?: (ops: Op[], label: string) => void;
3158
+ /** Optional pose-composition strategy for hierarchical (local-pose) scenes.
3159
+ * When absent, defaults to IDENTITY (absolute-pose: nodes store world
3160
+ * coords). Local-pose consumers supply { compose: composeRectPose,
3161
+ * decompose: decomposeRectPose } (or their pose shape's equivalent). */
3162
+ poseComposition?: PoseComposition<unknown>;
3163
+ /**
3164
+ * Ingestion dep — canvas viewport rect + consumer file→src resolver.
1594
3165
  *
1595
- * params may also be a thunk evaluated each time the
1596
- * dispatcher (or invoker) needs the value. Thunks let tools close over
1597
- * refs that mutate during a gesture (e.g. polygon `sides` adjusted
1598
- * mid-drag via ArrowUp). For ongoing invokers that want the latest
1599
- * values at commit, the invoker can re-call the thunk inside `onEnd`
1600
- * via `resolveParams(opts?.params)`. */
1601
- params?: Record<string, unknown> | (() => Record<string, unknown>);
3166
+ * Sourced from `<SceneCanvas>` / `<StandardActionsRegistrar>` via
3167
+ * `useIngestionDepSource`. Feeds `ingestAction` with the world-space
3168
+ * viewport rect for paste-placement and image fit-clamping, and forwards
3169
+ * the consumer's optional `resolveSrc` seam.
3170
+ *
3171
+ * Optional: when absent, the `ingest` action no-ops (there is no
3172
+ * placement geometry to work with).
3173
+ */
3174
+ ingestion?: IngestionDep;
3175
+ /**
3176
+ * Optional consumer seam for the eager-sync layer: lets pose-transform
3177
+ * actions (resize/move/nudge/flip — NOT rotate) ALSO rewrite a node's
3178
+ * data-held geometry. Given a node and the affine `m` applied to its pose,
3179
+ * `transform(node, m)` returns updated `data` (geometry mapped by `m`) or
3180
+ * `null` for nodes with no data-held geometry.
3181
+ *
3182
+ * Strictly opt-in: when absent (or when `transform` returns null), the kit
3183
+ * emits only the pose op and leaves `data` untouched. apps/draw wires this
3184
+ * to mirror `data.path` through `transformPath`. Rotate intentionally never
3185
+ * consults this seam (rotation lives on the pose, baked at render).
3186
+ */
3187
+ geometryProjection?: GeometryProjection;
1602
3188
  }
3189
+ /**
3190
+ * Every dep name the registry knows about — derived from {@link DepSchema} so
3191
+ * the two can't drift.
3192
+ *
3193
+ * Declared here rather than beside the registry so that this `keyof` reference
3194
+ * resolves to the exported `DepSchema` declaration; from another module it
3195
+ * resolves to that module's import alias, which the API docs can't link.
3196
+ */
3197
+ type DepName = keyof DepSchema;
1603
3198
 
1604
3199
  /**
1605
3200
  * GestureBinding — connects a GestureSpec to an Action id (with per-binding
@@ -1615,136 +3210,50 @@ interface GestureBinding {
1615
3210
  opts?: BindingOpts;
1616
3211
  }
1617
3212
 
1618
- /** The full vocabulary of capability tags shipped in the default preset.
1619
- * Apps and other consumers can add their own tags; this list is what
1620
- * `weasel-modes` itself uses. */
1621
- declare const ALL_TAGS: readonly ["navigation", "creates-selection", "creates-paths", "creates-shapes", "creates-text", "edits-anchors", "edits-text", "transforms-selection", "samples-color", "applies-fill", "edits-page"];
1622
- type CapabilityTag = (typeof ALL_TAGS)[number] | (string & {});
1623
-
1624
- interface WorkspaceVisual {
1625
- tint?: string;
1626
- gradient?: 'top-down' | 'bottom-up';
1627
- intensity?: number;
1628
- }
1629
- interface ModeDefinition {
1630
- id: string;
1631
- kind: 'soft' | 'strict';
1632
- /** Capability tags this mode allows beyond IMPLICIT_TAGS. */
1633
- allows: CapabilityTag[];
1634
- /** When true, out-of-target objects dim at the renderer layer. */
1635
- scoping: boolean;
1636
- workspace?: WorkspaceVisual;
1637
- entry?: {
1638
- shortcut?: string;
1639
- trigger?: 'double-click-target';
1640
- };
1641
- exit?: {
1642
- shortcut?: string;
1643
- };
1644
- commit?: {
1645
- shortcut?: string;
1646
- };
1647
- cancel?: {
1648
- shortcut?: string;
1649
- };
1650
- }
1651
-
1652
- interface ModeRegistry {
1653
- current(): ModeDefinition;
1654
- setMode(id: string): void;
1655
- byId(id: string): ModeDefinition;
1656
- getVersion(): number;
1657
- subscribe(listener: () => void): () => void;
1658
- }
1659
-
1660
- /**
1661
- * Configurable activation-key descriptor for tools that expose their
1662
- * keybinding to the host (currently Lasso and Eyedropper). Captures
1663
- * only the fields meaningful to a caller-supplied tool-select key —
1664
- * dispatcher-internal fields (`skipInEditable`, `enabled`,
1665
- * `preventDefault`) live on `KeyBinding` in keyHelpers.ts and are
1666
- * not part of the configurable surface.
1667
- */
1668
- interface ToolKeybinding {
1669
- /** Key or list of keys to match (case-insensitive against `event.key`). */
1670
- key: string | readonly string[];
1671
- /** Require Cmd (mac) / Ctrl (others). Default `false`. */
1672
- mod?: boolean;
1673
- /** Require Alt. Default `false`. */
1674
- alt?: boolean;
1675
- /**
1676
- * Shift policy. `undefined`/`false` forbids shift, `true` requires
1677
- * shift, `'optional'` allows either.
1678
- */
1679
- shift?: boolean | 'optional';
1680
- }
1681
-
1682
- /** Common payload for any hit that references a scene node. */
1683
- interface NodeRef {
1684
- id: NodeId;
1685
- pose: unknown;
1686
- data: unknown;
1687
- meta?: Record<string, unknown>;
1688
- }
1689
- /** No hit — pointer landed on the background. */
1690
- interface EmptyHit {
1691
- category: 'empty';
1692
- kind: 'empty';
1693
- }
1694
- /** Hit on a scene node's body. */
1695
- interface NodeHit extends NodeRef {
1696
- category: 'node';
1697
- kind: string;
1698
- }
1699
- /** Hit on a node's affordance chrome (handle, anchor, etc.). */
1700
- interface AffordanceHit extends NodeRef {
1701
- category: 'affordance';
1702
- kind: string;
1703
- }
1704
- /** Hit on a tool-defined target (anchor, handle, segment, etc.) supplied
1705
- * via the tool's `hitOverride`. The `kind` string is the tool's own
1706
- * vocabulary; the dispatcher does not interpret it. */
1707
- interface ToolHit {
1708
- category: 'tool';
1709
- kind: string;
1710
- extra?: unknown;
3213
+ type HandleKind = 'corner' | 'rotation' | 'anchor';
3214
+ type HitShape = {
3215
+ kind: 'rect';
3216
+ x: number;
3217
+ y: number;
3218
+ width: number;
3219
+ height: number;
3220
+ rotation?: number;
3221
+ } | {
3222
+ kind: 'circle';
3223
+ cx: number;
3224
+ cy: number;
3225
+ r: number;
3226
+ } | {
3227
+ kind: 'path';
3228
+ d: Path2D;
3229
+ };
3230
+ interface DebugSink {
3231
+ recordHitbox(id: string, kind: 'body' | 'handle' | 'rotation' | 'anchor', shape: HitShape): void;
3232
+ recordHandle(id: string, position: {
3233
+ x: number;
3234
+ y: number;
3235
+ }, kind: HandleKind): void;
3236
+ recordBounds(id: string, bounds: {
3237
+ x: number;
3238
+ y: number;
3239
+ width: number;
3240
+ height: number;
3241
+ }): void;
3242
+ recordOrigin(id: string, point: {
3243
+ x: number;
3244
+ y: number;
3245
+ }): void;
3246
+ recordSnapCandidate(point: {
3247
+ x: number;
3248
+ y: number;
3249
+ }, accepted: boolean): void;
3250
+ recordLayer(id: string, label: string, space: 'world' | 'screen', index: number): void;
3251
+ /** Clears every non-snap array. Called at the start of each Canvas render. */
3252
+ beginFrame(): void;
3253
+ /** Clears the snap array. Called at gesture end. */
3254
+ clearSnap(): void;
1711
3255
  }
1712
- /** Full discriminated union — every routed action's `ctx.target`. */
1713
- type HitResult = EmptyHit | NodeHit | AffordanceHit | ToolHit;
1714
3256
 
1715
- /** Phase the route was resolved against. Mirrors the spec's two-phase
1716
- * vocabulary — `initial` (idle, scratch null) or `engaged` (mid-gesture). */
1717
- type RoutePhase = 'initial' | 'engaged';
1718
- /** Gesture channel the route fired on. */
1719
- type RouteGesture = GestureName;
1720
- /** Snapshot of one route resolution, emitted by the factory on each
1721
- * successful lookup. Captured by the dispatcher as the "last resolved
1722
- * route" for debug-overlay consumers. */
1723
- interface RouteResolvedInfo {
1724
- toolId: string;
1725
- phase: RoutePhase;
1726
- gesture: RouteGesture;
1727
- /** Argument captured at match time for arg-bearing gestures
1728
- * (`wheel` direction, `keyDown`/`keyUp` key, `multiTouchTap` fingers).
1729
- * Undefined for no-arg gestures. */
1730
- arg: string | undefined;
1731
- /** Route-table key that matched (post-precedence). E.g. 'rect:selected',
1732
- * '*:selected', 'rect', '*', 'empty'. For function-form `drag` (no
1733
- * table), this is '*'. For keyDown/keyUp, it's the key name ('Escape',
1734
- * 'Enter', etc.). */
1735
- matchedKey: string;
1736
- modifiers: ModifierCombo;
1737
- /** The full HitResult at resolution time (snapshot — safe to read). */
1738
- target: HitResult;
1739
- /** Monotonic timestamp (ms since page load via performance.now()).
1740
- * Used for "resolved Nms ago" displays. */
1741
- timestamp: number;
1742
- }
1743
-
1744
- /** Outcome of a channel handler. `'claim'` stops dispatch for this event;
1745
- * `'pass'` lets the next slot try. Handlers that return nothing are
1746
- * treated as `'pass'`. */
1747
- type Decision = 'claim' | 'pass' | void;
1748
3257
  /** Modifier-key snapshot at event dispatch time. `space` is included
1749
3258
  * because tools commonly use space as a hotkey-slot trigger and may
1750
3259
  * also want to read it as a flag mid-gesture. */
@@ -1763,11 +3272,6 @@ interface ToolCtx<TScratch = unknown> {
1763
3272
  worldX: number;
1764
3273
  worldY: number;
1765
3274
  modifiers: ToolModifiers;
1766
- /** Hit-test result for the current event. Populated by the dispatcher
1767
- * before each handler call. Tools that don't use declarative routing
1768
- * can ignore this. Optional for migration; will become
1769
- * required once the routing migration is complete. */
1770
- target?: HitResult;
1771
3275
  selection: SelectionApi;
1772
3276
  /** Adapter/scene access — opaque at this layer; tools that need it
1773
3277
  * cast to a known shape. This layer doesn't constrain it. */
@@ -1797,40 +3301,8 @@ interface ToolCtx<TScratch = unknown> {
1797
3301
  * rotation handle, etc.) lands in the same overlay as Canvas's own
1798
3302
  * bounds/origin records. Tools should call this conditionally with `?.`. */
1799
3303
  debug?: DebugSink;
1800
- /** Kit-internal: route-resolution reporter. The dispatcher populates
1801
- * this; the declarative routing factory calls it after each successful
1802
- * resolveRoute() hit so the dispatcher can publish the last-resolved
1803
- * snapshot to debug-overlay consumers. Underscore prefix signals
1804
- * "do not consume in tool code." */
1805
- __reportRoute?: (info: RouteResolvedInfo) => void;
1806
3304
  scratch: TScratch;
1807
3305
  }
1808
- interface PointerChannel<TScratch> {
1809
- onDown?: (e: PointerEvent, ctx: ToolCtx<TScratch>) => Decision;
1810
- onClick?: (e: PointerEvent, ctx: ToolCtx<TScratch>) => Decision;
1811
- }
1812
- interface DragChannel<TScratch> {
1813
- onStart?: (e: PointerEvent, ctx: ToolCtx<TScratch>) => Decision;
1814
- onMove?: (e: PointerEvent, ctx: ToolCtx<TScratch>) => Decision;
1815
- onEnd?: (e: PointerEvent, ctx: ToolCtx<TScratch>) => Decision;
1816
- onCancel?: (ctx: ToolCtx<TScratch>) => void;
1817
- }
1818
- interface KeyboardChannel<TScratch> {
1819
- onDown?: (e: KeyboardEvent, ctx: ToolCtx<TScratch>) => Decision;
1820
- onUp?: (e: KeyboardEvent, ctx: ToolCtx<TScratch>) => Decision;
1821
- }
1822
- interface WheelChannel<TScratch> {
1823
- onWheel?: (e: WheelEvent, ctx: ToolCtx<TScratch>) => Decision;
1824
- }
1825
- /** Double-tap (double-click) channel. Fires on the pointerup of the second
1826
- * sub-threshold tap when it follows a previous sub-threshold tap within the
1827
- * dispatcher's `dblTap.windowMs` and `dblTap.maxDistance` (CSS px). The
1828
- * scratch handed to the handler is fresh — `dblTap` is not part of a drag
1829
- * pipeline, so `initScratch()` runs immediately before the call. A `'claim'`
1830
- * return suppresses the regular `pointer.onClick` for this gesture. */
1831
- interface DblTapChannel<TScratch> {
1832
- onTap?: (e: PointerEvent, ctx: ToolCtx<TScratch>) => Decision;
1833
- }
1834
3306
  /** World-space AABB shape used by `previewBounds`. Alias of the kit-wide
1835
3307
  * `Bounds` type — the optional `rotation` field carries through so a tool
1836
3308
  * can report an oriented preview rect (e.g. mid-rotate). */
@@ -1867,6 +3339,22 @@ interface Tool<TScratch = unknown> {
1867
3339
  * every implicit-or-declared tag (i.e. `normal` in the default preset).
1868
3340
  */
1869
3341
  capabilities?: CapabilityTag[];
3342
+ /**
3343
+ * Actions this tool owns and needs registered while it is in the tools
3344
+ * registry — e.g. polygon's `polygon.adjustSides`, which its own bindings
3345
+ * reference by id.
3346
+ *
3347
+ * Declared here rather than registered by the hook with `useAction`,
3348
+ * because tool hooks run wherever the consumer calls them — for
3349
+ * `<SceneCanvas>` that is ABOVE `<ActionsProviderIfRoot>`, where
3350
+ * `useActionsRegistry()` returns null and `useAction` silently no-ops. The
3351
+ * result was a binding pointing at an action id nothing had registered, so
3352
+ * the gesture fell through to whatever matched next (polygon's
3353
+ * wheel/arrow-key side adjustment did nothing and `nudge.*` moved the
3354
+ * selection instead). `<ToolActionsMounter>` registers these from inside
3355
+ * the provider.
3356
+ */
3357
+ actions?: Action[];
1870
3358
  /** Optional caller-supplied key. Most built-in tools have their activation
1871
3359
  * key declared in `BUILTIN_SELECT_KEYS` in `useKeybindings.ts`; this field
1872
3360
  * is for tools that want their activation key to be configurable by the
@@ -1878,25 +3366,6 @@ interface Tool<TScratch = unknown> {
1878
3366
  initScratch?: () => TScratch;
1879
3367
  onActivate?: (ctx: ToolCtx<TScratch>) => void;
1880
3368
  onDeactivate?: (ctx: ToolCtx<TScratch>) => void;
1881
- pointer?: PointerChannel<TScratch>;
1882
- drag?: DragChannel<TScratch>;
1883
- keyboard?: KeyboardChannel<TScratch>;
1884
- wheel?: WheelChannel<TScratch>;
1885
- /** Double-tap channel — fires when two sub-threshold taps land within
1886
- * `dblTap.windowMs` / `dblTap.maxDistance` of each other. Lets tools
1887
- * enter modal modes (e.g. select → edit-anchors) without consumers
1888
- * attaching `onDoubleClick` to a wrapper DOM node. */
1889
- dblTap?: DblTapChannel<TScratch>;
1890
- /**
1891
- * State-aware predicate. When true, this tool claims every pointerdown
1892
- * and bypasses the affordance layer hit-test pipeline. Used by tools
1893
- * in modal states (pen mid-path, text mid-edit) where affordance hits
1894
- * would otherwise interrupt the in-progress gesture.
1895
- *
1896
- * Default: undefined (treated as false). Called once per pointerdown
1897
- * with the tool's current ctx (scratch + view + modifiers).
1898
- */
1899
- claimsAll?: (ctx: ToolCtx<TScratch>) => boolean;
1900
3369
  cursor?: string | ((ctx: ToolCtx<TScratch>) => string);
1901
3370
  /** Presentation metadata for tool palettes. See `ToolPresentation`. */
1902
3371
  presentation?: ToolPresentation<TScratch>;
@@ -1924,37 +3393,18 @@ interface Tool<TScratch = unknown> {
1924
3393
  * nothing — typically gated on a scratch field like
1925
3394
  * `if (!scratch.overlay) return`. */
1926
3395
  overlay?: RenderLayer<unknown>;
1927
- /** Declarative gesture-bindings the
1928
- * dispatcher consults while this tool is active. Empty/undefined keeps
1929
- * legacy imperative-channel behavior. See
3396
+ /** Declarative gesture bindings the tool's entire input surface. The
3397
+ * gesture dispatcher consults these at active scope while this tool is
3398
+ * active, and at hotkey scope while it is held. See
1930
3399
  * `docs/superpowers/specs/2026-05-16-registry-unification-design.md`. */
1931
3400
  bindings?: GestureBinding[];
1932
- /**
1933
- * Optional. When set, the dispatcher consults this before its built-in
1934
- * node/empty hit-test. If it returns a value, that target replaces the
1935
- * default `target` on the routed action's ctx. The string `target` is
1936
- * the tool's own vocabulary — the dispatcher does not interpret it.
1937
- *
1938
- * Used for tools that need richer sub-object hit categories (e.g., pen
1939
- * edit-mode's anchor/handle/segment vs the default node/empty).
1940
- */
1941
- hitOverride?(ctx: {
1942
- worldX: number;
1943
- worldY: number;
1944
- scratch: TScratch;
1945
- view: View;
1946
- modifiers: ToolModifiers;
1947
- }): {
1948
- target: string;
1949
- extra?: unknown;
1950
- } | null;
1951
3401
  /** Reflection escape hatch: when this `Tool` was produced by `defineTool`,
1952
3402
  * the source `ToolDef` is attached here so introspection consumers
1953
- * (`buildActionRegistry`, `findConflicts`, the toolkit-builder UI, the
1954
- * reflection demo) can walk the declarative source rather than the
1955
- * translated runtime channels. Tools constructed without `defineTool`
1956
- * may leave this undefined. Typed as `unknown` to keep this file from
1957
- * importing the routing types — consumers cast at the use site. */
3403
+ * (`buildRouteRegistry`, `findConflicts`, the toolkit-builder UI, the
3404
+ * reflection demo) can read the authored form `hookName` in particular,
3405
+ * which the runtime `Tool` doesn't carry. Tools constructed without
3406
+ * `defineTool` may leave this undefined. Typed as `unknown` to keep this
3407
+ * file from importing the routing types — consumers cast at the use site. */
1958
3408
  def?: unknown;
1959
3409
  }
1960
3410
  /** Internal alias for "a Tool of any scratch type" — used in registries and
@@ -1965,161 +3415,187 @@ type AnyTool = Tool<any>;
1965
3415
 
1966
3416
  /**
1967
3417
  * @experimental
1968
- * Result of an affordance hit what the dispatcher wires up as the gesture.
1969
- * Nominates the drag channel and (optionally) initial scratch state.
1970
- */
1971
- interface AffordanceBinding<TScratch = unknown> {
1972
- drag: DragChannel<TScratch>;
1973
- /** Initial scratch passed to drag.onStart. Lets the affordance pre-fill
1974
- * state from what its region's binding already computed (anchor: 'br',
1975
- * targetId: 'g1', etc.) so the tool's onStart doesn't re-derive it. */
1976
- initialScratch?: TScratch;
1977
- }
1978
-
1979
- /**
1980
- * Canvas size in CSS pixels — passed to `draw` for layers that anchor to
1981
- * canvas edges (e.g. the debug overlay's layer-list panel). The GL backend
1982
- * supplies it explicitly so layers don't have to know about DPR.
3418
+ * A single entry in `Action.defaultBinding[]`. Either a bare `GestureSpec`
3419
+ * (no per-binding opts) or an object form that pairs a spec with
3420
+ * `BindingOpts` for parametric actions (e.g. `{ params: { axis: 'x' } }`).
3421
+ * Use the object form when two bindings for the same action differ only in
3422
+ * a runtime parameter — the dispatcher extracts `opts.params` and passes
3423
+ * them to `ImmediateInvoker.run` as its second argument.
1983
3424
  */
1984
- interface Dims {
1985
- width: number;
1986
- height: number;
1987
- }
3425
+ type BoundGesture = GestureSpec | {
3426
+ spec: GestureSpec;
3427
+ opts: BindingOpts;
3428
+ };
1988
3429
  /**
1989
- * A single named render sub-layer within a canvas renderer.
1990
- *
1991
- * @template TData - The data object passed to each draw call.
3430
+ * @experimental
3431
+ * Single registered action. v1: one binding per action.
1992
3432
  */
1993
- interface RenderLayer<TData> {
1994
- /** Unique identifier used in visibility maps and ordering arrays. */
3433
+ interface Action {
1995
3434
  id: string;
1996
- /** Human-readable name for UI toggles. */
1997
3435
  label: string;
3436
+ /** The gesture-spec form of the binding, read by the gesture dispatcher.
3437
+ * May be a single `GestureSpec`, a bare `GestureSpec[]` (any-of semantics),
3438
+ * or a `BoundGesture[]` where each entry is either a bare `GestureSpec` or
3439
+ * `{ spec, opts }` — use the object form for parametric actions where two
3440
+ * bindings for the same action differ only by `opts.params` (e.g. `flip`
3441
+ * with `axis: 'x'` vs `'y'`). The dispatcher extracts `opts.params` and
3442
+ * passes them to `ImmediateInvoker.run` as its second argument. */
3443
+ defaultBinding?: GestureSpec | BoundGesture[];
3444
+ /** Names of the deps this action's invoker reads (keys of `DepSchema`).
3445
+ * The dispatcher (and `trigger`, when `requires` is present) resolves
3446
+ * each name against the `DepRegistry` at invocation time and passes the
3447
+ * resulting bag to the invoker. Dev builds warn when the invoker reads a
3448
+ * dep it didn't declare here — see `buildDepsFromRequires`. */
3449
+ requires?: readonly DepName[];
3450
+ /** Inline-SVG icon for palette / toolbar surfaces. Mirrors
3451
+ * `ToolPresentation.icon` so a generic `<ActionBar>` can render from
3452
+ * action metadata the same way `<ToolPalette>` renders from tool
3453
+ * metadata. May be a static `ReactNode` or a function (rare; useful
3454
+ * for state-aware icons like a "lock" toggle). */
3455
+ icon?: ReactNode | (() => ReactNode);
3456
+ /** Grouping key for palette/menu surfaces. Free-form string; the kit
3457
+ * ships defaults for `'align'` (six edges/centers), `'distribute'`
3458
+ * (two axes), and recommends `'pathfinder'` for boolean ops. */
3459
+ group?: string;
3460
+ /** Display override for the keyboard shortcut. When omitted, palette
3461
+ * surfaces derive a label from `defaultBinding` via their own
3462
+ * formatter. */
3463
+ shortcut?: string;
3464
+ /** Pluggable invocation strategy. The gesture dispatcher routes matched
3465
+ * bindings through `invoker.start` / `invoker.run` depending on timing.
3466
+ * All kit-standard descriptors ship one; consumer-supplied actions
3467
+ * without an invoker can still register but won't be triggered. */
3468
+ invoker?: Invoker;
3469
+ /** When set to `'hotkey'`, this action's `defaultBinding` rides the hotkey
3470
+ * `BindingScope` instead of the ambient scope — meaning it beats any
3471
+ * active-tool binding on the same input shape. Use for tool-switch
3472
+ * shortcuts and global held-key triggers. Default: ambient. */
3473
+ scope?: 'hotkey';
1998
3474
  /**
1999
- * Emit a DrawCommand tree for the GL backend to dispatch.
3475
+ * @experimental
3476
+ * Optional predicate the command palette consults when rendering. Return
3477
+ * `true` when the action is currently triggerable. Return a reason string
3478
+ * (e.g. `'Selection required'`) when disabled — the palette greys out
3479
+ * the row, skips it in keyboard nav, ignores clicks, and shows the
3480
+ * reason next to the label. Keystroke dispatch (the registered binding)
3481
+ * is unaffected; the action's own `run` should self-guard.
2000
3482
  *
2001
- * For world-space layers (the default), emit commands in WORLD COORDS —
2002
- * `drawLayers` automatically wraps them in `{ kind: 'group', transform:
2003
- * viewToMat3(view), ... }` before handing them to the renderer. Do NOT
2004
- * apply the view transform yourself.
3483
+ * **Contract:** must be pure (no side effects), fast (< 4ms in dev), and
3484
+ * must not throw. If a call throws or exceeds the budget in dev mode,
3485
+ * `evaluateEnabled` logs a one-time warning per action id; throws are
3486
+ * caught and treated as disabled with reason `'(predicate threw)'`.
2005
3487
  *
2006
- * For screen-space layers (`space: 'screen'`), emit commands in CSS-pixel
2007
- * coords directly; `drawLayers` passes them through unchanged. If part
2008
- * of a screen-space layer's output needs to track the view, wrap that
2009
- * subset manually with `viewToMat3(view)`.
2010
- */
2011
- draw: (data: TData, view: View, dims: Dims) => DrawCommand[];
2012
- /**
2013
- * Whether the layer is shown when no explicit visibility entry exists.
2014
- * Defaults to `true` when absent.
2015
- */
2016
- defaultVisible?: boolean;
2017
- /**
2018
- * When true, the layer is always drawn regardless of the visibility map.
2019
- * Useful for layers that must never be hidden (e.g. base grid).
2020
- */
2021
- alwaysOn?: boolean;
2022
- /**
2023
- * Coordinate space the layer draws in.
3488
+ * Snapshot-on-open semantics: the palette evaluates `enabled` once when
3489
+ * opened and does NOT re-evaluate on selection changes while open. Live
3490
+ * reactive updates are deferred palette is short-lived.
2024
3491
  *
2025
- * - `'world'` (default): the layer's `draw` returns world-space commands;
2026
- * `drawLayers` wraps them in a `kind: 'group'` with `viewToMat3(view)`
2027
- * automatically.
2028
- * - `'screen'`: the layer's `draw` returns screen-space (CSS-pixel)
2029
- * commands; `drawLayers` passes them through unchanged. World-anchored
2030
- * chrome inside a screen-space layer must call `worldToScreen` or wrap
2031
- * the relevant subset with `viewToMat3(view)` manually.
3492
+ * The reason set is a closed enum to add a new reason, edit
3493
+ * `ActionDisabledReason` and the consumer's display map.
3494
+ *
3495
+ * The optional `deps` argument is the same bag passed to
3496
+ * `ImmediateInvoker.run`; callers (`evaluateEnabled` / the ActionBar) may
3497
+ * synthesize it from the surrounding `DepRegistry` so predicates can
3498
+ * inspect selection / scene / etc. Predicates that don't need deps just
3499
+ * ignore the arg.
2032
3500
  */
2033
- space?: 'world' | 'screen';
3501
+ enabled?: (deps?: ActionDeps) => true | ActionDisabledReason;
2034
3502
  /**
2035
- * Optional hit-test. When defined, the dispatcher consults this on
2036
- * pointerdown (top-down layer order) before falling through to the
2037
- * active tool's slot walk. First non-null result wins; null means
2038
- * "I don't claim this hit, try the next layer."
3503
+ * Declarative eligibility rule, evaluated against the current
3504
+ * `RuleCtx` by the dispatcher before invoking `start()`. Omitted =
3505
+ * always eligible.
2039
3506
  *
2040
- * Coordinates are world-space. The `data` arg is the layer's
2041
- * configured data slot (same as `draw`); `view` and `dims` mirror
2042
- * `draw`'s arguments.
3507
+ * Accepts either a fluent `Condition` (callable with `.rule`) or a
3508
+ * raw `Rule` tree; the dispatcher normalizes via `.rule` unwrap.
3509
+ *
3510
+ * Prefer `capability:`-based rules (e.g. `{ capability: 'transforms-selection' }`)
3511
+ * over `mode:` rules — capability rules survive new modes being added
3512
+ * that allow the same capability.
2043
3513
  */
2044
- hitTest?: (worldX: number, worldY: number, data: TData, view: View, dims: Dims,
2045
- /** Chrome-caps visibility predicate. When supplied, the layer must
2046
- * not return a hit from any chrome element whose id reports
2047
- * `false`. Absent → every element is hittable. */
2048
- isVisible?: (id: string) => boolean) => AffordanceBinding | null;
3514
+ eligible?: Rule | Condition;
2049
3515
  /**
2050
- * Called on every pointermove when no gesture is currently captured.
2051
- * Lets layers (e.g. HUD widgets) track hover state without participating
2052
- * in the drag pipeline. Coords are world-space; the layer is responsible
2053
- * for any further conversion (e.g. world→screen for screen-space layers)
2054
- * and for its own throttling.
3516
+ * CSS cursor shown while the pointer hovers a spot where this action
3517
+ * would win the drag. The hover-cursor pump (in `useGestureDispatcher`)
3518
+ * runs `Dispatcher.resolveOnly` on each idle pointermove the same
3519
+ * match walk a real pointerdown takes and applies the winning
3520
+ * action's `cursor`, so the hint and the actual click target stay in
3521
+ * sync by construction. Omitted = no override (the active tool's
3522
+ * `Tool.cursor` shows). Affordance hits are resolved earlier in the
3523
+ * pump via `AffordanceRegion.cursor` and never reach this field.
3524
+ *
3525
+ * Static string only. Prediction runs `enabled()` but cannot run the
3526
+ * invoker, so an action that matches yet bails at `start()` (empty
3527
+ * handle) may still show its cursor — keep `enabled` accurate for
3528
+ * actions that declare one.
2055
3529
  */
2056
- onUncapturedMove?: (worldX: number, worldY: number, evt: PointerEvent, view: View, dims: Dims) => void;
3530
+ cursor?: string;
2057
3531
  /**
2058
- * Called when the cursor leaves the canvas element. Lets layers clear
2059
- * any hover state they're holding.
3532
+ * CSS cursor shown while THIS action's ongoing handle is in flight —
3533
+ * grabbing while panning, `move` while dragging a selection, `crosshair`
3534
+ * while pulling a marquee.
3535
+ *
3536
+ * Separate from `cursor` because the two answer different questions:
3537
+ * `cursor` is a prediction ("a drag from here would pan"), this is a state
3538
+ * ("you are panning"). An action can declare either, both, or neither;
3539
+ * with only `cursor` set, the hover hint holds for the duration of the
3540
+ * gesture.
3541
+ *
3542
+ * This is where mid-gesture cursors live now. They used to come from the
3543
+ * tool side — `ViewportToolDef.engaged.cursor` for a phase-gated string,
3544
+ * or a function-form `Tool.cursor` reading the gesture scratch out of the
3545
+ * tool-routing dispatcher. Both belonged to a pipeline whose whole job was
3546
+ * being taken over by bindings, and neither could describe a cursor for an
3547
+ * action a tool doesn't own.
2060
3548
  */
2061
- onUncapturedLeave?: () => void;
3549
+ activeCursor?: string;
2062
3550
  }
3551
+ /**
3552
+ * @experimental
3553
+ * Closed enum of reasons an action might report itself as disabled. The
3554
+ * consumer (palette, menu, etc.) maps these symbolic values to display
3555
+ * strings via its own label map — see `demo/CommandPalette.tsx` for the
3556
+ * canonical mapping.
3557
+ */
3558
+ declare const ActionDisabledReason: {
3559
+ readonly SelectionRequired: "selection-required";
3560
+ readonly SceneEmpty: "scene-empty";
3561
+ readonly NotApplicable: "not-applicable";
3562
+ /** Sentinel: the predicate threw. Surfaced by `evaluateEnabled`'s catch. */
3563
+ readonly PredicateThrew: "predicate-threw";
3564
+ };
3565
+ type ActionDisabledReason = (typeof ActionDisabledReason)[keyof typeof ActionDisabledReason];
2063
3566
 
2064
- interface ToolsDispatcher {
2065
- onPointerDown: (e: PointerEvent) => void;
2066
- onPointerMove: (e: PointerEvent) => void;
2067
- onPointerUp: (e: PointerEvent) => void;
2068
- onKeyDown: (e: KeyboardEvent) => void;
2069
- onKeyUp: (e: KeyboardEvent) => void;
2070
- onWheel: (e: WheelEvent) => void;
2071
- /** Force-cancel any in-flight gesture (used on explicit tool switch). */
2072
- cancelGesture: () => void;
2073
- /** Whether a gesture is currently in flight. Used by `useTools` to
2074
- * decide whether a modifier-key press should engage the hotkey slot
2075
- * (no, if mid-gesture). */
2076
- hasActiveGesture: () => boolean;
2077
- /** Scratch of the in-flight gesture, or `null` when idle. Exposed so
2078
- * consumers (cursor resolution, debug overlays) can read what the active
2079
- * tool is currently tracking. Read-only — do NOT mutate via this getter. */
2080
- getActiveScratch: () => unknown;
2081
- /** Most recent route resolution emitted by a declarative tool, or null
2082
- * if none has fired yet. Snapshot — safe to read on every render. */
2083
- getLastRoute: () => RouteResolvedInfo | null;
2084
- /** Resolve a synthetic (phase, gesture, hit, modifiers) query against the
2085
- * current slot occupants WITHOUT executing the matched action. Walks slots
2086
- * in real precedence order (hotkey > active > ambient) and consults each
2087
- * tool's attached `def` (the declarative `ToolDef` produced by
2088
- * `defineTool`). Tools without an attached `def` (imperative-only) are
2089
- * invisible to this query. Returns the first match, or null if no slot
2090
- * resolves the query. Pure: no scratch mutation, no scene mutation, no
2091
- * RouteResolvedInfo emission. */
2092
- resolveOnly: (query: ResolveQuery) => ResolveResult | null;
2093
- }
2094
- /** Synthetic resolution query — what the static widget asks "if a pointer
2095
- * event landed on `hit` with these `modifiers`, which declarative route
2096
- * would the dispatcher fire in this phase + gesture?" */
2097
- interface ResolveQuery {
2098
- phase: 'initial' | 'engaged';
2099
- gesture: 'click' | 'drag' | 'pointerDown' | 'dblTap' | 'wheel';
2100
- hit: HitResult;
2101
- modifiers: ToolModifiers;
2102
- }
2103
- /** Successful resolution: which tool, in which slot, matched which route-table
2104
- * key. `matchedKey` is `'*'` for function-form drag (no table to discriminate)
2105
- * and for wheel routes (single ActionFn). */
2106
- interface ResolveResult {
2107
- toolId: string;
2108
- slot: 'hotkey' | 'active' | 'ambient';
2109
- gesture: 'click' | 'drag' | 'pointerDown' | 'dblTap' | 'wheel';
2110
- phase: 'initial' | 'engaged';
2111
- matchedKey: string;
3567
+ /**
3568
+ * Configurable activation-key descriptor for tools that expose their
3569
+ * keybinding to the host (currently Lasso and Eyedropper). Captures
3570
+ * only the fields meaningful to a caller-supplied tool-select key —
3571
+ * dispatcher-internal fields (`skipInEditable`, `enabled`,
3572
+ * `preventDefault`) live on `KeyBinding` in keyHelpers.ts and are
3573
+ * not part of the configurable surface.
3574
+ */
3575
+ interface ToolKeybinding {
3576
+ /** Key or list of keys to match (case-insensitive against `event.key`). */
3577
+ key: string | readonly string[];
3578
+ /** Require Cmd (mac) / Ctrl (others). Default `false`. */
3579
+ mod?: boolean;
3580
+ /** Require Alt. Default `false`. */
3581
+ alt?: boolean;
3582
+ /**
3583
+ * Shift policy. `undefined`/`false` forbids shift, `true` requires
3584
+ * shift, `'optional'` allows either.
3585
+ */
3586
+ shift?: boolean | 'optional';
2112
3587
  }
2113
3588
 
2114
3589
  interface ToolsApi {
2115
3590
  /** Current active-slot tool id. */
2116
3591
  active: string;
2117
- /** Set the active-slot tool. Cancels any in-flight gesture. */
3592
+ /** Set the active-slot tool. The gesture dispatcher watches the active
3593
+ * tool and cancels any in-flight handle itself. */
2118
3594
  setActive: (id: string) => void;
2119
3595
  /** Currently hotkey-engaged tool id (or `null`). Derived as the top of
2120
3596
  * the hotkey stack for backwards compat with the pre-stack API. */
2121
3597
  hotkeyEngaged: string | null;
2122
- /** Engage a hotkey-slot tool by id. No-op if a gesture is in flight. */
3598
+ /** Engage a hotkey-slot tool by id. */
2123
3599
  engageHotkey: (id: string) => void;
2124
3600
  /** Disengage the hotkey-slot tool, if any. */
2125
3601
  disengageHotkey: () => void;
@@ -2127,13 +3603,6 @@ interface ToolsApi {
2127
3603
  ambient: readonly AnyTool[];
2128
3604
  /** Full registry — for userland UI (palette buttons, etc.). */
2129
3605
  registry: Readonly<Record<string, AnyTool>>;
2130
- /** The dispatcher `<Canvas>` wires to its DOM events. */
2131
- dispatcher: ToolsDispatcher;
2132
- /** Increments whenever an in-flight gesture starts, transitions phase, or
2133
- * ends. Consumers (e.g. `<Canvas>` cursor resolution) include this in
2134
- * their render deps to re-evaluate derived state on real DOM events
2135
- * rather than waiting for an unrelated re-render. */
2136
- gestureTick: number;
2137
3606
  /** Returns true if a tool with the given id is in the registry or ambient list. */
2138
3607
  has(id: string): boolean;
2139
3608
  /** All overlay layers from currently-engaged tools (active slot, hotkey
@@ -2445,6 +3914,13 @@ type SelectOption = {
2445
3914
  value: string;
2446
3915
  label: ReactNode;
2447
3916
  isDisabled?: boolean;
3917
+ /**
3918
+ * Plain-text form of `label`, for type-to-select and screen readers.
3919
+ * Only needed when `label` isn't a bare string — a label built from
3920
+ * elements has no text React Aria can read off it. A string label
3921
+ * supplies this itself.
3922
+ */
3923
+ textValue?: string;
2448
3924
  };
2449
3925
  type Key$1 = string | number;
2450
3926
  type SelectProps<T extends Key$1 = string> = Omit<SelectProps$1<object>, 'children' | 'className' | 'selectedKey' | 'defaultSelectedKey' | 'onSelectionChange'> & {
@@ -2475,7 +3951,7 @@ type SelectItemProps = Omit<ListBoxItemProps, 'className' | 'children'> & {
2475
3951
  children?: ReactNode;
2476
3952
  className?: string;
2477
3953
  };
2478
- declare function SelectItem({ children, className, ...rest }: SelectItemProps): react_jsx_runtime.JSX.Element;
3954
+ declare function SelectItem({ children, className, textValue, ...rest }: SelectItemProps): react_jsx_runtime.JSX.Element;
2479
3955
 
2480
3956
  type ComboBoxOption = {
2481
3957
  value: string;
@@ -2600,7 +4076,7 @@ interface Plot2DProps {
2600
4076
  /** Pointer down on the SVG. Receives both plot- and model-space coords
2601
4077
  * pre-computed so consumers don't repeat the rect/transform dance. */
2602
4078
  onPointerDown?: (e: PointerEvent$1<SVGSVGElement>, coords: Plot2DCoords) => void;
2603
- onKeyDown?: (e: KeyboardEvent$1<SVGSVGElement>) => void;
4079
+ onKeyDown?: (e: KeyboardEvent<SVGSVGElement>) => void;
2604
4080
  children?: ReactNode;
2605
4081
  }
2606
4082
  interface Plot2DHandle {