@weasel-js/routing 1.6.1 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,8 @@
1
- import { CapabilityTag } from '@weasel-js/modes';
1
+ import { CapabilityTag, ActiveMode, ModeRegistry } from '@weasel-js/modes';
2
2
  import * as react from 'react';
3
3
  import { ReactNode } from 'react';
4
4
  import { CursorSpec } from '@weasel-js/cursor';
5
- import { ClaimableGesture, GestureSpec, InputEvent, TargetSpec, ParsedModifiers, GestureName, IngestItem } from '@weasel-js/gestures';
5
+ import { ClaimableGesture, GestureSpec, InputEvent, TargetSpec, KeySpec, ParsedModifiers, GestureName, IngestItem } from '@weasel-js/gestures';
6
6
  import { Op } from '@weasel-js/history';
7
7
 
8
8
  /**
@@ -382,6 +382,24 @@ interface Condition {
382
382
  orNot(other: Condition | Rule): Condition;
383
383
  }
384
384
 
385
+ /**
386
+ * Whether two rules can never hold at once — no `RuleCtx` passes both.
387
+ *
388
+ * Conservative: `true` is a proof, `false` means "may overlap". It reasons
389
+ * about the selector values themselves (two different `mode`s, an empty
390
+ * selection against a non-empty one, `focused: true` against `false`) and
391
+ * through `all` / `any` / `not`; a `when` closure is opaque, so it overlaps
392
+ * everything. The route-conflict check uses it to stay quiet about bindings
393
+ * that share a tuple but are never eligible together.
394
+ */
395
+ declare function rulesExclusive(a: Rule, b: Rule): boolean;
396
+ /**
397
+ * Whether `rule` can hold while `mode` is active. Only its `mode` and
398
+ * `capability` tests are settled by the mode; every other test could go either
399
+ * way, so `false` is a proof and `true` means "may hold".
400
+ */
401
+ declare function ruleCanHoldIn(rule: Rule, mode: ActiveMode): boolean;
402
+
385
403
  /** The detected half of a profile — everything except the derived scale. */
386
404
  type DetectedDeviceFacts = Omit<DeviceProfile, 'targetScale'>;
387
405
  /**
@@ -1079,7 +1097,7 @@ interface ActionDispatch extends BindingSource {
1079
1097
  * gesture.
1080
1098
  *
1081
1099
  * This is where mid-gesture cursors live now. They used to come from the
1082
- * tool side — `ViewportToolDef.engaged.cursor` for a phase-gated string,
1100
+ * tool side — an `engaged` phase entry's cursor,
1083
1101
  * or a function-form `Tool.cursor` reading the gesture scratch out of the
1084
1102
  * tool-routing dispatcher. Both belonged to a pipeline whose whole job was
1085
1103
  * being taken over by bindings, and neither could describe a cursor for an
@@ -1150,6 +1168,65 @@ interface ActionEnabledResult {
1150
1168
  */
1151
1169
  declare function evaluateEnabled(action: Action, deps?: ActionDeps): ActionEnabledResult;
1152
1170
 
1171
+ /**
1172
+ * The tree both input registries hang on. A provider is a root; every canvas
1173
+ * mounts a scope under the registry in scope; a yoke sits between the two. A
1174
+ * registration lives in the node it was made in, and a lookup starts at a leaf
1175
+ * and walks up, so one canvas never answers for another.
1176
+ *
1177
+ * Design: docs/proposals/2026-09-27-input-scopes.md.
1178
+ */
1179
+ /** What every node of one tree shares: a version and the listeners to it. */
1180
+ interface ScopeTree {
1181
+ version: number;
1182
+ readonly listeners: Set<() => void>;
1183
+ }
1184
+ declare class ScopeNode<N extends ScopeNode<N>> {
1185
+ readonly parent: N | null;
1186
+ readonly descends: boolean;
1187
+ readonly tree: ScopeTree;
1188
+ private readonly children;
1189
+ private activeChild;
1190
+ /** `descends`: whether reads made here answer from the active child down —
1191
+ * true for a root or a yoke, which chrome reads through; false for a scope,
1192
+ * which answers for itself even with a scope nested inside it. */
1193
+ constructor(parent: N | null, descends: boolean);
1194
+ /** Bump the tree's version and tell every listener in it. */
1195
+ changed(): void;
1196
+ /** Join the parent's children, newest last; returns the leave. */
1197
+ mount(): () => void;
1198
+ /** Make this node the one its ancestors resolve through. */
1199
+ activate(): void;
1200
+ /** The node a lookup made here starts from: down the active children, or the
1201
+ * newest where nothing has been activated yet. A yoke with no members yet
1202
+ * is passed over — there is no canvas behind it to answer. */
1203
+ leaf(): N;
1204
+ private newestMember;
1205
+ /** Where a read made through this node starts. */
1206
+ start(): N;
1207
+ /** Whether this node is on the path a read from the root takes. */
1208
+ isActive(): boolean;
1209
+ /** This node, then each ancestor up to the root. */
1210
+ chain(): Generator<N>;
1211
+ }
1212
+
1213
+ /**
1214
+ * Dep registry for registry unification.
1215
+ *
1216
+ * Holds named "live source" thunks. Actions declare `requires: ['selection']`;
1217
+ * the dispatcher calls `registry.get('selection')` at invocation time to
1218
+ * build a typed Deps bag.
1219
+ *
1220
+ * `DepSchema` — the map of dep name → value type — is the single source of
1221
+ * truth for what `requires`/`register`/`get` accept. Its concrete fields live
1222
+ * in `./depSchema` (co-located with the dep value types). It is declared there
1223
+ * as a plain `export interface` rather than via a cross-module
1224
+ * `declare module './depRegistry'` augmentation: that augmentation does NOT
1225
+ * merge once rollup-plugin-dts flattens both files into one `.d.ts` chunk,
1226
+ * which silently emptied `DepSchema` for consumers. Consumers still extend it
1227
+ * via `declare module '@weasel-js/core'` augmentation.
1228
+ */
1229
+
1153
1230
  /** Holds the live sources an action's declared dependencies resolve to.
1154
1231
  * Sources are thunks, read at invocation time, so an action never captures
1155
1232
  * stale state. */
@@ -1157,23 +1234,20 @@ interface DepRegistry {
1157
1234
  register<K extends DepName>(name: K, source: () => DepSchema[K]): () => void;
1158
1235
  get<K extends DepName>(name: K): DepSchema[K] | undefined;
1159
1236
  }
1160
- /** Provides the dep registry for a canvas. `<SceneCanvas>` mounts one; a
1161
- * consumer registering its own dep sources must be inside it. */
1162
- declare function DepRegistryProvider({ children }: {
1163
- children: ReactNode;
1164
- }): react.JSX.Element;
1165
- /** The dep registry in scope. Throws outside a `<DepRegistryProvider>`. */
1166
- declare function useDepRegistry(): DepRegistry;
1167
- /**
1168
- * Like `useDepRegistry`, but returns `null` when no `<DepRegistryProvider>` is
1169
- * in scope instead of throwing. Used by `useStandardActions` to preserve its
1170
- * silent-no-op contract when neither provider is present.
1171
- */
1172
- declare function useOptionalDepRegistry(): DepRegistry | null;
1173
- /** Register a live source for `name` for the lifetime of the calling
1174
- * component. The `source` thunk is called at dispatch time and should
1175
- * return the latest value. */
1176
- declare function useDepSource<K extends DepName>(name: K, source: () => DepSchema[K]): void;
1237
+ /** One node of the dep tree: the sources registered at it, over its parent's.
1238
+ * Each name holds a stack of sources, newest live, so a displaced source is
1239
+ * restored when the one above it leaves. */
1240
+ declare class DepNode extends ScopeNode<DepNode> {
1241
+ private readonly sources;
1242
+ readonly registry: DepRegistry;
1243
+ constructor(parent: DepNode | null, descends: boolean);
1244
+ }
1245
+ /**
1246
+ * A stock dep registry with no parent: what `<DepRegistryProvider>` mounts,
1247
+ * for a dispatcher driven without React. Sources registered under one name
1248
+ * stack, newest live, and releasing one restores the one beneath it.
1249
+ */
1250
+ declare function createDepRegistry(): DepRegistry;
1177
1251
 
1178
1252
  /**
1179
1253
  * Shared dep-bag builder — the single implementation behind both the gesture
@@ -1187,9 +1261,13 @@ declare function useDepSource<K extends DepName>(name: K, source: () => DepSchem
1187
1261
  declare function buildDepsFromRequires(action: Action, depRegistry: DepRegistry): ActionDeps;
1188
1262
 
1189
1263
  /** Hotkey-slot trigger key. The slot is engaged while this key is held —
1190
- * hence "hotkey": active as long as the key is hot. `null` (or omitted)
1191
- * means the tool is not eligible for the hotkey slot. */
1192
- type HotkeyTrigger = 'space' | 'alt' | 'ctrl' | 'meta' | 'shift';
1264
+ * hence "hotkey": active as long as the key is hot. Omitted means the tool
1265
+ * is not eligible for the hotkey slot.
1266
+ *
1267
+ * The named keys map to their DOM names (`'space'` is `' '`, `'ctrl'` is
1268
+ * `'Control'`). Anything else is a `KeyboardEvent.key` value, matched
1269
+ * case-insensitively as every key spec is — so `'o'` holds on O. */
1270
+ type HotkeyTrigger = 'space' | 'alt' | 'ctrl' | 'meta' | 'shift' | (string & {});
1193
1271
  /** Presentation metadata for tool palettes / menus. Optional on every
1194
1272
  * tool — consumers that render a palette (`<ToolPalette>`) read these
1195
1273
  * fields to display the tool; consumers that don't can ignore them.
@@ -1209,6 +1287,10 @@ interface ToolPresentation<TScratch = unknown> {
1209
1287
  /** Display override for the keyboard shortcut. When omitted the palette
1210
1288
  * derives one from `Tool.keybinding` via its own formatter. */
1211
1289
  shortcut?: string;
1290
+ /** Keep the tool off palettes — for a tool that is only ever held, like a
1291
+ * scrub engaged by its `hotkey`. It stays registered and selectable by
1292
+ * id; only the palette leaves it out. */
1293
+ hide?: boolean;
1212
1294
  }
1213
1295
  /**
1214
1296
  * When an entry's bindings are live. A set, not one value: the hand tool is
@@ -1255,7 +1337,7 @@ interface ContributionRouting {
1255
1337
  * What an entry draws and how a palette shows it. None of it reaches the
1256
1338
  * dispatcher.
1257
1339
  */
1258
- interface ContributionChrome<TOverlay = unknown> {
1340
+ interface ContributionChrome<TOverlay = KernelOverlay> {
1259
1341
  /** One layer, or several composed in the given order.
1260
1342
  *
1261
1343
  * Routing never reads a layer — it collects the live ones and hands them
@@ -1271,117 +1353,11 @@ interface ContributionChrome<TOverlay = unknown> {
1271
1353
  * is optional and independent — an entry that only routes input declares only
1272
1354
  * `bindings` and `actions`.
1273
1355
  */
1274
- interface Contribution<TOverlay = unknown> extends ContributionRouting, ContributionChrome<TOverlay> {
1356
+ interface Contribution<TOverlay = KernelOverlay> extends ContributionRouting, ContributionChrome<TOverlay> {
1275
1357
  /** Reflection escape hatch — the authored form, when there was one. */
1276
1358
  def?: unknown;
1277
1359
  }
1278
1360
 
1279
- /**
1280
- * Configurable activation-key descriptor for tools that expose their
1281
- * keybinding to the host (currently Lasso and Eyedropper). Captures
1282
- * only the fields meaningful to a caller-supplied tool-select key —
1283
- * dispatcher-internal fields (`skipInEditable`, `enabled`,
1284
- * `preventDefault`) live on `KeyBinding` in keyHelpers.ts and are
1285
- * not part of the configurable surface.
1286
- */
1287
- interface ToolKeybinding {
1288
- /** Key or list of keys to match (case-insensitive against `event.key`). */
1289
- key: string | readonly string[];
1290
- /** Require Cmd (mac) / Ctrl (others). Default `false`. */
1291
- mod?: boolean;
1292
- /** Require Alt. Default `false`. */
1293
- alt?: boolean;
1294
- /**
1295
- * Shift policy. `undefined`/`false` forbids shift, `true` requires
1296
- * shift, `'optional'` allows either.
1297
- */
1298
- shift?: boolean | 'optional';
1299
- }
1300
- /** Modifier-key snapshot at event dispatch time. */
1301
- type ToolModifiers = ModifierState;
1302
- /** Per-event context passed to every channel handler. `scratch` is typed
1303
- * via the tool's `TScratch` parameter; it survives across a single
1304
- * gesture (pointer-down through end/cancel) and is replaced on next
1305
- * gesture start by `initScratch()`. */
1306
- interface ToolCtx<TScratch = unknown> {
1307
- worldX: number;
1308
- worldY: number;
1309
- modifiers: ToolModifiers;
1310
- selection: SelectionApi;
1311
- /** Adapter/scene access — opaque at this layer; tools that need it
1312
- * cast to a known shape. This layer doesn't constrain it. */
1313
- adapter: unknown;
1314
- applyOps: (ops: Op[], label: string) => void;
1315
- /** Current viewport. Reflects camera-position semantics — see
1316
- * `View` JSDoc. */
1317
- view: View;
1318
- /** Mutate the viewport. In controlled mode this calls the consumer's
1319
- * `onViewChange`; in uncontrolled mode it updates Canvas's internal
1320
- * state. View changes are not undoable. */
1321
- setView: (next: View) => void;
1322
- /** Bounding rect of the canvas element in viewport coords. Used by
1323
- * zoom/pan tools to convert event clientX/clientY to canvas-relative
1324
- * anchors. */
1325
- canvasRect: DOMRect;
1326
- /** Optional debug sink. When `<Canvas debug={...}>` is enabled, Canvas
1327
- * threads its sink here so tool-internal hit math (handle hitboxes,
1328
- * rotation handle, etc.) lands in the same overlay as Canvas's own
1329
- * bounds/origin records. Tools should call this conditionally with `?.`. */
1330
- debug?: DebugSink;
1331
- scratch: TScratch;
1332
- }
1333
- /** World-space AABB shape used by `previewBounds`. Alias of the kit-wide
1334
- * `Bounds` type — the optional `rotation` field carries through so a tool
1335
- * can report an oriented preview rect (e.g. mid-rotate). */
1336
- type ToolBounds = Bounds;
1337
- /**
1338
- * The focus-declaring case of a `Contribution`: a mode the user switches
1339
- * into, plus the hooks that only make sense for one (`initScratch`,
1340
- * activate/deactivate, live preview, `cursor`). Everything else — bindings,
1341
- * actions, overlay, presentation — is inherited.
1342
- */
1343
- interface Tool<TScratch = unknown, TOverlay = unknown> extends Contribution<TOverlay> {
1344
- /** Optional caller-supplied key. Most built-in tools have their activation
1345
- * key declared in `BUILTIN_SELECT_KEYS` in `useKeybindings.ts`; this field
1346
- * is for tools that want their activation key to be configurable by the
1347
- * host (currently Lasso and Eyedropper). The dynamic loop in
1348
- * `useKeybindings.ts` picks this up and appends a binding entry to the
1349
- * consolidated `tool.activate` action (with `opts.params.toolId` set so
1350
- * the invoker knows which tool to switch to). */
1351
- keybinding?: ToolKeybinding;
1352
- initScratch?: () => TScratch;
1353
- cursor?: CursorSpec | ((ctx: ToolCtx<TScratch>) => CursorSpec);
1354
- onActivate?: (ctx: ToolCtx<TScratch>) => void;
1355
- onDeactivate?: (ctx: ToolCtx<TScratch>) => void;
1356
- /** Returns the in-flight preview pose for `id` if this tool is mid-gesture
1357
- * on it; otherwise `null`. Lets `Canvas.helpersRef.getEffectivePose`
1358
- * reflect live gesture state without reaching into hook internals. The
1359
- * return type is `unknown` here because the Tool interface is pose-agnostic;
1360
- * callers that know the pose shape (e.g. Canvas typed by `TPose`) cast at
1361
- * the use site. */
1362
- previewPose?: (id: string) => unknown;
1363
- /** Returns the in-flight preview bounds for `id` if this tool is mid-gesture
1364
- * on it; otherwise `null`. Optional companion to `previewPose` for tools that
1365
- * can compute bounds without round-tripping through a geometry adapter. */
1366
- previewBounds?: (id: string) => ToolBounds | null;
1367
- /** Returns ids whose committed scene-render should be suppressed while this
1368
- * tool is mid-gesture (e.g. cascade move's dragged + descendant ids whose
1369
- * preview ghosts replace the committed pose). The standard scene slot
1370
- * consults this alongside `previewPose` to avoid double-rendering. Returns
1371
- * `null` when no gesture is in flight. */
1372
- previewIds?: () => Iterable<string> | null;
1373
- }
1374
- /** Internal — which slot a tool occupies in the dispatch order. */
1375
- type ToolSlot = 'hotkey' | 'active' | 'ambient';
1376
- /** Internal alias for "a Tool of any scratch type" — used in registries and
1377
- * dispatchers that hold tools of heterogeneous scratch shapes. `any` is
1378
- * intentional: `Tool<TScratch>` is invariant in TScratch, so `Tool<unknown>`
1379
- * is too strict for containers that accept any concrete `Tool<T>`. */
1380
- type AnyTool = Tool<any, any>;
1381
- /** "A tool of any scratch type that draws `TOverlay`" — the container form for
1382
- * registries that hold heterogeneous scratch shapes but one overlay type. */
1383
- type AnyToolOf<TOverlay> = Tool<any, TOverlay>;
1384
-
1385
1361
  /**
1386
1362
  * Pure matcher primitives live in `@weasel-js/gestures`. This file
1387
1363
  * re-exports them for kit-internal consumers and layers the actions-layer
@@ -1396,18 +1372,18 @@ type BindingScope = 'ambient' | 'active' | 'hotkey';
1396
1372
  interface ScopedBinding {
1397
1373
  binding: GestureBinding;
1398
1374
  scope: BindingScope;
1399
- /** Tool id that owns this binding — `'&'`-channel phase atoms resolve
1400
- * to this. `null` for ambient bindings that came from a registered
1401
- * Action with no owning tool. */
1402
- ownerToolId: string | null;
1375
+ /** Id of the tool or contribution that declared this binding —
1376
+ * `'&'`-channel phase atoms resolve to it. `null` for a registered
1377
+ * action's own binding. */
1378
+ ownerId: string | null;
1403
1379
  }
1404
1380
  /** The binding that won a match. */
1405
1381
  interface MatchResult {
1406
1382
  binding: GestureBinding;
1407
1383
  scope: BindingScope;
1408
- /** Tool id that owns the binding — propagated from `ScopedBinding`
1409
- * so the dispatcher can record it as the handle owner. */
1410
- ownerToolId: string | null;
1384
+ /** Id of the entry that declared the binding — propagated from
1385
+ * `ScopedBinding` so the dispatcher can record it as the handle owner. */
1386
+ ownerId: string | null;
1411
1387
  }
1412
1388
  /**
1413
1389
  * True when a spec's target actually consults the affordance hit rather than
@@ -1422,19 +1398,6 @@ interface MatchResult {
1422
1398
  * that declare nothing.
1423
1399
  */
1424
1400
  declare function targetConsultsAffordance(specTarget: TargetSpec | undefined): boolean;
1425
- /** CSS-style specificity tuple for a GestureSpec. Higher tuple wins under
1426
- * lexicographic compare. Dimensions, in order of precedence:
1427
- *
1428
- * [0] target — how much the spec's target narrows; see `targetRank`.
1429
- * [1] mods — count of required modifier keys (shift/alt/ctrl/meta/mod).
1430
- * `'optional'` does NOT count.
1431
- * [2] phase — how much the spec's `phase` narrows; see `phaseRank`.
1432
- * [3] exact — per-kind tiebreak: 2 for a drop/paste spec with a
1433
- * non-empty `types` MIME filter, else 1.
1434
- *
1435
- * Identical tuples fall back to registration order in the matcher's
1436
- * stable sort, preserving the pre-specificity tiebreaker. */
1437
- declare function specificity(spec: GestureSpec): readonly [number, number, number, number];
1438
1401
  /**
1439
1402
  * Find the best-matching binding across all scopes.
1440
1403
  *
@@ -1463,6 +1426,167 @@ declare function matchBest(e: InputEvent, bindings: readonly ScopedBinding[], is
1463
1426
  * action's `start()` returns an empty handle), the next match is tried.
1464
1427
  */
1465
1428
  declare function matchSorted(e: InputEvent, bindings: readonly ScopedBinding[], isMac: boolean, engagedChannels?: ReadonlySet<string>, warn?: (message: string) => void): MatchResult[];
1429
+ /** {@link matchSorted}, plus what an exclusive claim on the event barred. */
1430
+ interface MatchOutcome {
1431
+ matches: MatchResult[];
1432
+ /** Bindings whose spec matched the event but which the claim barred, in
1433
+ * the same order `matches` uses. Filled only when `collectBarred` is set. */
1434
+ barred: MatchResult[];
1435
+ /** Owner of the exclusive claim, when one applied. */
1436
+ claimOwner?: string;
1437
+ }
1438
+ /**
1439
+ * {@link matchSorted} that can also report the bindings an exclusive claim
1440
+ * kept out. Collecting them costs a spec match per barred binding, so it is
1441
+ * opt-in — the dispatcher asks only when it is recording.
1442
+ */
1443
+ declare function matchSortedWithBarred(e: InputEvent, bindings: readonly ScopedBinding[], isMac: boolean, engagedChannels?: ReadonlySet<string>, opts?: {
1444
+ collectBarred?: boolean;
1445
+ warn?: (message: string) => void;
1446
+ }): MatchOutcome;
1447
+
1448
+ /**
1449
+ * How one input was resolved: every binding that matched it, what each filter
1450
+ * dropped, the order the rest were ranked in and why, and what the walk down
1451
+ * that order did. The dispatcher fills one per input in dev builds, and
1452
+ * `Dispatcher.explain` fills one on request without invoking anything.
1453
+ *
1454
+ * Plain data — no functions, no live objects — so it can be kept in a trace
1455
+ * buffer, serialized, or built by hand as a fixture.
1456
+ */
1457
+ interface DispatchRecord {
1458
+ kind: 'dispatch';
1459
+ ts: number;
1460
+ input: DispatchRecordInput;
1461
+ /** Every binding whose spec matched the input, in matcher order, including
1462
+ * those a filter then dropped. */
1463
+ matched: readonly RecordCandidate[];
1464
+ dropped: readonly DroppedCandidate[];
1465
+ /** The candidates that survived both filters, best first. */
1466
+ ranked: readonly RankedCandidate[];
1467
+ /** True when nothing was invoked: the record answers "what would happen". */
1468
+ predicted: boolean;
1469
+ /** Action id of the candidate that fired (or would), else `null`. */
1470
+ fired: string | null;
1471
+ outcome: 'handled' | 'unhandled';
1472
+ }
1473
+ interface DispatchRecordInput {
1474
+ /** `InputEvent.kind`: `'key'`, `'pointerdown'`, `'wheel'`, … */
1475
+ eventKind: string;
1476
+ /** For `key` / `key-held` events, the key id (`'Escape'`, `' '`, `'a'`). */
1477
+ key?: string;
1478
+ modifiers: {
1479
+ alt: boolean;
1480
+ ctrl: boolean;
1481
+ meta: boolean;
1482
+ shift: boolean;
1483
+ };
1484
+ /** The view the input was routed to; `null` for the surface's own camera. */
1485
+ viewId: string | null;
1486
+ /** World point, for events that carry one. */
1487
+ world?: {
1488
+ x: number;
1489
+ y: number;
1490
+ };
1491
+ /** Active mode id, when the host supplies a rule context. */
1492
+ mode?: string;
1493
+ }
1494
+ /** One matched binding. */
1495
+ interface RecordCandidate {
1496
+ actionId: string;
1497
+ /** The binding's spec in route grammar — one per arg alternative. */
1498
+ routes: readonly string[];
1499
+ scope: BindingScope;
1500
+ /** The tool or contribution that declared the binding; `null` for an action's own binding. */
1501
+ ownerId: string | null;
1502
+ /** Whether the binding's `views` names the routed view. */
1503
+ namesView: boolean;
1504
+ /** `specificity(spec)`: target, modifiers, phase, exact. */
1505
+ specificity: readonly [number, number, number, number];
1506
+ /** The action's `eligible` rule as text, when it declares one. */
1507
+ eligible?: string;
1508
+ }
1509
+ type DroppedCandidate =
1510
+ /** An exclusive claim on the input barred bindings that do not read the
1511
+ * claim's affordance. */
1512
+ {
1513
+ candidate: RecordCandidate;
1514
+ filter: 'claim';
1515
+ owner?: string;
1516
+ }
1517
+ /** The action's `eligible` rule does not hold. */
1518
+ | {
1519
+ candidate: RecordCandidate;
1520
+ filter: 'ineligible';
1521
+ rule: string;
1522
+ };
1523
+ type SpecificityPart = 'target' | 'mods' | 'phase' | 'exact';
1524
+ /** The ranking step that put a candidate below the one before it. */
1525
+ type PlacedBy = {
1526
+ step: 'first';
1527
+ }
1528
+ /** The one above names the routed view; this one does not. */
1529
+ | {
1530
+ step: 'view';
1531
+ }
1532
+ /** The one above sits in a higher scope tier. */
1533
+ | {
1534
+ step: 'tier';
1535
+ }
1536
+ /** Same tier; the one above is more specific in `part`. */
1537
+ | {
1538
+ step: 'specificity';
1539
+ part: SpecificityPart;
1540
+ }
1541
+ /** Tied; the one above is gated by an `eligible` rule that holds. */
1542
+ | {
1543
+ step: 'context';
1544
+ }
1545
+ /** Tied on everything; registration order. */
1546
+ | {
1547
+ step: 'order';
1548
+ };
1549
+ /** What the walk did with one ranked candidate. */
1550
+ type WalkStep = {
1551
+ kind: 'fired';
1552
+ }
1553
+ /** Predicted records only: this candidate would fire. Nothing was started,
1554
+ * so an ongoing action here may still bail at `start()` for real. */
1555
+ | {
1556
+ kind: 'would-fire';
1557
+ }
1558
+ /** `enabled()` returned this reason. */
1559
+ | {
1560
+ kind: 'declined';
1561
+ reason: string;
1562
+ }
1563
+ /** An ongoing action's `start()` returned an empty handle. */
1564
+ | {
1565
+ kind: 'empty-handle';
1566
+ }
1567
+ /** An ongoing action bound to a press-time `pointerDown` spec; refused. */
1568
+ | {
1569
+ kind: 'misbound';
1570
+ }
1571
+ /** Its action was already tried higher up; each runs at most once. */
1572
+ | {
1573
+ kind: 'duplicate';
1574
+ } | {
1575
+ kind: 'no-such-action';
1576
+ }
1577
+ /** Below the winner; never asked. */
1578
+ | {
1579
+ kind: 'not-asked';
1580
+ }
1581
+ /** Below the winner, asked anyway (`evaluateShadowed`), and it passed. */
1582
+ | {
1583
+ kind: 'outranked';
1584
+ };
1585
+ interface RankedCandidate {
1586
+ candidate: RecordCandidate;
1587
+ placedBy: PlacedBy;
1588
+ walk: WalkStep;
1589
+ }
1466
1590
 
1467
1591
  /**
1468
1592
  * Dispatcher orchestrator — pure module, no React, no DOM.
@@ -1472,10 +1596,19 @@ declare function matchSorted(e: InputEvent, bindings: readonly ScopedBinding[],
1472
1596
  * on `enabled()`; then invokes `immediate` or `ongoing` invokers and tracks
1473
1597
  * in-flight handles.
1474
1598
  *
1475
- * ## Specificity-ordered fall-through
1476
- * `matchSorted` returns every matching binding in precedence order
1477
- * (hotkey > active > ambient, first-declared within scope). The dispatcher
1478
- * walks that list and fires the first action whose `enabled()` returns
1599
+ * ## Precedence and fall-through
1600
+ * An exclusive affordance claim first bars every binding that does not
1601
+ * consult the affordance. The rest are ranked, best first, by:
1602
+ * 1. bindings naming the routed view ahead of every other
1603
+ * (`preferViewScoped`);
1604
+ * 2. scope tier: hotkey > active > ambient;
1605
+ * 3. specificity within a tier (`specificity()` in `matcher.ts`);
1606
+ * 4. an action gated by an `eligible` rule that holds now ahead of one with
1607
+ * no rule (`preferContextual`) — Escape while editing a path exits the
1608
+ * edit rather than resetting the tool;
1609
+ * 5. registration order.
1610
+ * Candidates whose `eligible` rule is false are dropped first. The dispatcher
1611
+ * walks the rest and fires the first action whose `enabled()` returns
1479
1612
  * `true`. If every candidate's `enabled()` returns a disabled reason, the
1480
1613
  * event is unhandled. This mirrors CSS-style specificity matching with a
1481
1614
  * `:not(:disabled)` filter, and lets a tool declare a high-specificity
@@ -1515,23 +1648,6 @@ declare function matchSorted(e: InputEvent, bindings: readonly ScopedBinding[],
1515
1648
  * synthesized event with no originating DOM pointer.
1516
1649
  */
1517
1650
  declare function pointerGestureId(pointerId: number | undefined): string;
1518
- /** One entry per `handleInput` call. */
1519
- interface DispatchLogEntry {
1520
- kind: 'dispatch';
1521
- ts: number;
1522
- eventKind: string;
1523
- /** For `key` / `key-held` events, the key id (`'Escape'`, `' '`, `'a'`).
1524
- * Lives on the event side of the entry — `fired` should describe
1525
- * what dispatched, not which key triggered it. */
1526
- key?: string;
1527
- candidates: Array<{
1528
- actionId: string;
1529
- scope: BindingScope;
1530
- enabledResult: boolean | string;
1531
- }>;
1532
- fired: string | null;
1533
- outcome: 'handled' | 'unhandled';
1534
- }
1535
1651
  /** One entry per "mode change" — a kit state transition that isn't a
1536
1652
  * dispatched input event but is load-bearing for understanding why an
1537
1653
  * input was (or wasn't) handled later. Pushed via `recordModeSwitch()`;
@@ -1548,7 +1664,17 @@ interface ModeSwitchLogEntry {
1548
1664
  * `'selection-dropped target'`, etc. */
1549
1665
  detail?: string;
1550
1666
  }
1551
- type TraceLogEntry = DispatchLogEntry | ModeSwitchLogEntry;
1667
+ /** A trace entry: one {@link DispatchRecord} per resolved input, or a mode switch. */
1668
+ type TraceLogEntry = DispatchRecord | ModeSwitchLogEntry;
1669
+ /**
1670
+ * Dev only: offer the record for what a press at the pointer would do, as a
1671
+ * thunk on `window.__weaselDispatchLive__`. The hover pump offers one per move
1672
+ * and a reader builds the record only when it looks, so no record is built
1673
+ * while nothing is watching. `null` withdraws it when the pointer leaves.
1674
+ *
1675
+ * @internal
1676
+ */
1677
+ declare function publishLiveDispatch(explain: (() => DispatchRecord) | null): void;
1552
1678
  /** Push a mode-switch record into the trace log. Safe to call from
1553
1679
  * anywhere; no-ops outside DEV. */
1554
1680
  declare function recordModeSwitch(mode: string, from: string | null, to: string | null, detail?: string): void;
@@ -1562,12 +1688,12 @@ interface DispatcherContext {
1562
1688
  actions: ActionSource;
1563
1689
  /** Dep sources keyed by name. */
1564
1690
  depRegistry: DepRegistry;
1565
- /** Active tool's id (from ActiveToolContext). */
1566
- activeToolId: string;
1691
+ /** Active tool's id (from ActiveToolContext); `null` when none is. */
1692
+ activeToolId: string | null;
1567
1693
  /** Held-hotkey stack, top of stack last. */
1568
1694
  hotkeyStack: readonly string[];
1569
- /** Lookup for tool definitions. */
1570
- toolsById: ReadonlyMap<string, Tool>;
1695
+ /** Every registered entry, tools and ambient contributions alike, by id. */
1696
+ entriesById: ReadonlyMap<string, Contribution<unknown>>;
1571
1697
  /** Platform flag for `mod` shorthand resolution. */
1572
1698
  isMac: boolean;
1573
1699
  /**
@@ -1599,6 +1725,23 @@ declare function filterEligible<M extends {
1599
1725
  actionId: string;
1600
1726
  };
1601
1727
  }>(matches: readonly M[], actionLookup: (id: string) => Action | undefined, ruleCtx: RuleCtx): M[];
1728
+ /**
1729
+ * Among matches tied on scope and specificity, put those whose action is
1730
+ * gated by an `eligible` rule that holds now ahead of those with no rule: a
1731
+ * binding that applies only in the current context — Escape while editing a
1732
+ * path — outranks one that applies everywhere. Otherwise stable.
1733
+ *
1734
+ * @internal
1735
+ */
1736
+ declare function preferContextual<M extends {
1737
+ binding: GestureBinding;
1738
+ scope: string;
1739
+ }>(matches: readonly M[], actionLookup: (id: string) => Action | undefined,
1740
+ /** Whether a gated action's rule holds; skip re-asking where the matches
1741
+ * were already filtered by it. */
1742
+ holds: (action: Action) => boolean,
1743
+ /** The routed view: a binding naming it is never tied with one that does not. */
1744
+ view: string | null): M[];
1602
1745
  /**
1603
1746
  * Handle returned by `Dispatcher.beginUiOngoing()` for driving an
1604
1747
  * ongoing invoker from a UI control (color picker, slider).
@@ -1624,7 +1767,7 @@ interface ResolveOnlyResult {
1624
1767
  action: Action;
1625
1768
  scope: BindingScope;
1626
1769
  /** Tool id owning the winning binding; `null` for ambient action bindings. */
1627
- ownerToolId: string | null;
1770
+ ownerId: string | null;
1628
1771
  }
1629
1772
  /**
1630
1773
  * One candidate from `Dispatcher.resolveAll` — a binding that matched the
@@ -1648,7 +1791,7 @@ interface ResolvedCandidate {
1648
1791
  action: Action;
1649
1792
  binding: GestureBinding;
1650
1793
  scope: BindingScope;
1651
- ownerToolId: string | null;
1794
+ ownerId: string | null;
1652
1795
  /** The tuple from `specificity(binding.spec)`, surfaced so a reader can see
1653
1796
  * why one candidate outranks another rather than inferring it. */
1654
1797
  specificity: readonly [number, number, number, number];
@@ -1736,6 +1879,14 @@ interface Dispatcher {
1736
1879
  * {@link ResolveAllOptions.evaluateShadowed}.
1737
1880
  */
1738
1881
  resolveAll(event: InputEvent, ctx: DispatcherContext, opts?: ResolveAllOptions): ResolvedCandidate[];
1882
+ /**
1883
+ * The full {@link DispatchRecord} for `event` — what matched, what each
1884
+ * filter dropped, how the rest ranked and why, and the walk — without
1885
+ * invoking anything, so `predicted` is true and the winner is `would-fire`.
1886
+ * It is the same computation `handleInput` and `resolveAll` run. Costs more
1887
+ * than `resolveOnly`: it renders every candidate's routes and rule.
1888
+ */
1889
+ explain(event: InputEvent, ctx: DispatcherContext, opts?: ResolveAllOptions): DispatchRecord;
1739
1890
  /**
1740
1891
  * Synthesize an end-of-gesture for every in-flight ongoing handle.
1741
1892
  * Used by tool-switch cancellation (Q2 decision).
@@ -1856,10 +2007,9 @@ interface ActionsRegistry {
1856
2007
  /** Drop every registrant of `id`. This is the "this action should not exist"
1857
2008
  * door, not a release — for that, call what `register` returned. */
1858
2009
  unregister(id: string): void;
1859
- /** Declare `id` not for this scope: it stays registered, and every other
1860
- * scope over the same store still resolves it, but here it lists as absent
1861
- * and neither `trigger` nor `begin` will fire it. Returns a release; an
1862
- * `<ActionsScope>` drops what it muted when it unmounts. This is the
2010
+ /** Declare `id` not for this scope: it stays registered wherever it was,
2011
+ * but here — and in scopes below — it lists as absent and neither
2012
+ * `trigger` nor `begin` will fire it. Returns a release. This is the
1863
2013
  * "not for me" door — `unregister` is the "should not exist" one. */
1864
2014
  mute(id: string): () => void;
1865
2015
  list(): readonly Action[];
@@ -1888,18 +2038,16 @@ interface ActionsRegistry {
1888
2038
  */
1889
2039
  begin(id: string, params?: Record<string, unknown>): UiOngoingControl | null;
1890
2040
  /** Wire a dispatcher into the registry so `begin()` can delegate to it.
1891
- * Returns a release that clears the slot only while this dispatcher still
1892
- * holds it: a canvas displaced by a later one must not take input away from
1893
- * the canvas now on screen. Call with `null` to detach unconditionally. */
2041
+ * Returns a release that takes out only this dispatcher; a `null` wires
2042
+ * nothing. */
1894
2043
  setDispatcher(d: Dispatcher | null): () => void;
1895
- /** Wire a `DepRegistry` into the registry so `trigger()` / `begin()` can
1896
- * resolve action deps even when this provider is mounted ABOVE the dep
1897
- * registry (e.g. a consumer's root `<ActionsProvider>` reused by
1898
- * SceneCanvas's `ActionsProviderIfRoot`). Takes precedence over the dep
1899
- * registry read from context at the provider's own level. Call with
1900
- * `null` to detach unconditionally; the returned release clears the slot
1901
- * only while this registry still holds it. */
1902
- setDepRegistry(r: DepRegistry | null): () => void;
2044
+ /** Make this registry's scope the one chrome above it reaches: `trigger`,
2045
+ * `begin` and `list` on any ancestor then answer from here. A dispatcher
2046
+ * calls it on the pointerdown and focus that land in its scope. */
2047
+ activate(): void;
2048
+ /** Whether a lookup made from the root of this registry's tree starts here —
2049
+ * the question a dispatcher asks before handling a keystroke. */
2050
+ isActive(): boolean;
1903
2051
  }
1904
2052
  /** Validate that `id` is usable as an action id in the route grammar.
1905
2053
  * Rejects ids that start with a reserved sigil (would shadow future
@@ -1922,8 +2070,8 @@ interface ToolOffhandBindingSpec {
1922
2070
  toolId: string;
1923
2071
  key: string | string[];
1924
2072
  }
1925
- /** `KeyboardEvent.key` for a declared `Eligibility.offhand` trigger. Only
1926
- * `space` is exercised today; the modifier arms follow the DOM's names. */
2073
+ /** `KeyboardEvent.key` for a declared `Eligibility.offhand` trigger. The
2074
+ * named keys become their DOM names; any other trigger already is one. */
1927
2075
  declare function offhandKeyFor(trigger: HotkeyTrigger): string;
1928
2076
  /** Build the per-tool `BoundGesture` list for the consolidated action's
1929
2077
  * `defaultBinding`. Each entry pairs a `key-held` spec with
@@ -2051,7 +2199,7 @@ declare function liveScope(id: string, eligibility: Eligibility, state: Eligibil
2051
2199
  * breaks ties by declaration order, so the caller's entry order decides which
2052
2200
  * of two same-specificity bindings in one tier wins.
2053
2201
  */
2054
- declare function scopeBindings(entries: Iterable<Contribution>, state: EligibilityState): ScopedBinding[];
2202
+ declare function scopeBindings(entries: Iterable<Contribution<unknown>>, state: EligibilityState): ScopedBinding[];
2055
2203
 
2056
2204
  /**
2057
2205
  * Concatenate contribution bundles into one registry, preserving order —
@@ -2064,6 +2212,165 @@ declare function scopeBindings(entries: Iterable<Contribution>, state: Eligibili
2064
2212
  */
2065
2213
  declare function mergeContributions<T extends Contribution<unknown>>(...bundles: readonly (readonly T[])[]): T[];
2066
2214
 
2215
+ /**
2216
+ * What each mode shortcut does. A role left out binds nothing, so its key
2217
+ * falls through to whatever else the dispatcher has on it.
2218
+ */
2219
+ interface ModeShortcutHandlers {
2220
+ /** Enter mode `id` — the `entry` shortcut. */
2221
+ enter?: (id: string) => void;
2222
+ /** Whether mode `id` can be entered now. A mode that needs a target it
2223
+ * doesn't have answers no, and its entry key falls through. Default yes. */
2224
+ canEnter?: (id: string) => boolean;
2225
+ /** Leave the active soft mode, keeping its session to resume. */
2226
+ exit?: () => void;
2227
+ /** Leave the active soft mode and throw its session away. */
2228
+ discard?: () => void;
2229
+ /** Commit the active strict mode's transaction. */
2230
+ commit?: () => void;
2231
+ /** Abandon the active strict mode's transaction. */
2232
+ cancel?: () => void;
2233
+ }
2234
+ /**
2235
+ * A `ModeDefinition` shortcut chord — `'Escape'`, `'Meta+T'` — as a key spec.
2236
+ * Modifier names are case-insensitive; the last segment is the key.
2237
+ */
2238
+ declare function modeShortcutSpec(shortcut: string): KeySpec;
2239
+ /**
2240
+ * The registry's declared mode shortcuts as an always-on contribution: one
2241
+ * action per shortcut, bound to its chord and gated on the mode it acts on —
2242
+ * `exit` / `discard` / `commit` / `cancel` while that mode is active, `entry`
2243
+ * while it is not. Install it with `<SceneCanvas ambient>`.
2244
+ *
2245
+ * The bindings ride the hotkey tier, so leaving a mode outranks what the same
2246
+ * key does otherwise (Escape clearing the selection), and they yield while a
2247
+ * gesture is in flight, so Escape mid-drag still cancels the drag first. Keys
2248
+ * typed into an editable element never reach them.
2249
+ */
2250
+ declare function modeShortcuts(registry: ModeRegistry, handlers: ModeShortcutHandlers): Contribution<never>;
2251
+
2252
+ /**
2253
+ * Configurable activation-key descriptor for tools that expose their
2254
+ * keybinding to the host (currently Lasso and Eyedropper). Captures
2255
+ * only the fields meaningful to a caller-supplied tool-select key —
2256
+ * dispatcher-internal fields (`skipInEditable`, `enabled`,
2257
+ * `preventDefault`) live on `KeyBinding` in keyHelpers.ts and are
2258
+ * not part of the configurable surface.
2259
+ */
2260
+ interface ToolKeybinding {
2261
+ /** Key or list of keys to match (case-insensitive against `event.key`). */
2262
+ key: string | readonly string[];
2263
+ /** Require Cmd (mac) / Ctrl (others). Default `false`. */
2264
+ mod?: boolean;
2265
+ /** Require Alt. Default `false`. */
2266
+ alt?: boolean;
2267
+ /**
2268
+ * Shift policy. `undefined`/`false` forbids shift, `true` requires
2269
+ * shift, `'optional'` allows either.
2270
+ */
2271
+ shift?: boolean | 'optional';
2272
+ }
2273
+ /** Modifier-key snapshot at event dispatch time. */
2274
+ type ToolModifiers = ModifierState;
2275
+ /** Per-event context passed to every channel handler. `scratch` is typed
2276
+ * via the tool's `TScratch` parameter; it survives across a single
2277
+ * gesture (pointer-down through end/cancel) and is replaced on next
2278
+ * gesture start by `initScratch()`. */
2279
+ interface ToolCtx<TScratch = unknown> {
2280
+ worldX: number;
2281
+ worldY: number;
2282
+ modifiers: ToolModifiers;
2283
+ selection: SelectionApi;
2284
+ /** Adapter/scene access — opaque at this layer; tools that need it
2285
+ * cast to a known shape. This layer doesn't constrain it. */
2286
+ adapter: unknown;
2287
+ applyOps: (ops: Op[], label: string) => void;
2288
+ /** Current viewport. Reflects camera-position semantics — see
2289
+ * `View` JSDoc. */
2290
+ view: View;
2291
+ /** Mutate the viewport. In controlled mode this calls the consumer's
2292
+ * `onViewChange`; in uncontrolled mode it updates Canvas's internal
2293
+ * state. View changes are not undoable. */
2294
+ setView: (next: View) => void;
2295
+ /** Bounding rect of the canvas element in viewport coords. Used by
2296
+ * zoom/pan tools to convert event clientX/clientY to canvas-relative
2297
+ * anchors. */
2298
+ canvasRect: DOMRect;
2299
+ /** Optional debug sink. When `<Canvas debug={...}>` is enabled, Canvas
2300
+ * threads its sink here so tool-internal hit math (handle hitboxes,
2301
+ * rotation handle, etc.) lands in the same overlay as Canvas's own
2302
+ * bounds/origin records. Tools should call this conditionally with `?.`. */
2303
+ debug?: DebugSink;
2304
+ scratch: TScratch;
2305
+ }
2306
+ /**
2307
+ * What `onActivate` / `onDeactivate` receive: the tool's scratch, from
2308
+ * `initScratch()`. A lifecycle moment has no pointer, hit or modifier state to
2309
+ * report, so nothing else is populated; a callback needing the selection or
2310
+ * the scene closes over it, as the tool's actions do.
2311
+ */
2312
+ interface ToolLifecycleCtx<TScratch = unknown> {
2313
+ scratch: TScratch;
2314
+ }
2315
+ /** World-space AABB shape used by `previewBounds`. Alias of the kit-wide
2316
+ * `Bounds` type — the optional `rotation` field carries through so a tool
2317
+ * can report an oriented preview rect (e.g. mid-rotate). */
2318
+ type ToolBounds = Bounds;
2319
+ /**
2320
+ * The focus-declaring case of a `Contribution`: a mode the user switches
2321
+ * into, plus the hooks that only make sense for one (`initScratch`,
2322
+ * activate/deactivate, live preview, `cursor`). Everything else — bindings,
2323
+ * actions, overlay, presentation — is inherited.
2324
+ */
2325
+ interface Tool<TScratch = unknown, TOverlay = KernelOverlay> extends Contribution<TOverlay> {
2326
+ /** Optional caller-supplied key. Most built-in tools have their activation
2327
+ * key declared in `BUILTIN_SELECT_KEYS` in `useKeybindings.ts`; this field
2328
+ * is for tools that want their activation key to be configurable by the
2329
+ * host (currently Lasso and Eyedropper). The dynamic loop in
2330
+ * `useKeybindings.ts` picks this up and appends a binding entry to the
2331
+ * consolidated `tool.activate` action (with `opts.params.toolId` set so
2332
+ * the invoker knows which tool to switch to). */
2333
+ keybinding?: ToolKeybinding;
2334
+ initScratch?: () => TScratch;
2335
+ cursor?: CursorSpec | ((ctx: ToolCtx<TScratch>) => CursorSpec);
2336
+ /** The tool became live: it took the active slot, or its `hotkey` went
2337
+ * down, while it held neither. */
2338
+ onActivate?: (ctx: ToolLifecycleCtx<TScratch>) => void;
2339
+ /** The tool stopped being live in both slots, or its canvas unmounted. */
2340
+ onDeactivate?: (ctx: ToolLifecycleCtx<TScratch>) => void;
2341
+ /** Returns the in-flight preview pose for `id` if this tool is mid-gesture
2342
+ * on it; otherwise `null`. Lets `Canvas.helpersRef.getEffectivePose`
2343
+ * reflect live gesture state without reaching into hook internals. The
2344
+ * return type is `unknown` here because the Tool interface is pose-agnostic;
2345
+ * callers that know the pose shape (e.g. Canvas typed by `TPose`) cast at
2346
+ * the use site. */
2347
+ previewPose?: (id: string) => unknown;
2348
+ /** Returns the in-flight preview bounds for `id` if this tool is mid-gesture
2349
+ * on it; otherwise `null`. Optional companion to `previewPose` for tools that
2350
+ * can compute bounds without round-tripping through a geometry adapter. */
2351
+ previewBounds?: (id: string) => ToolBounds | null;
2352
+ /** Returns ids whose committed scene-render should be suppressed while this
2353
+ * tool is mid-gesture (e.g. cascade move's dragged + descendant ids whose
2354
+ * preview ghosts replace the committed pose). The standard scene slot
2355
+ * consults this alongside `previewPose` to avoid double-rendering. Returns
2356
+ * `null` when no gesture is in flight. */
2357
+ previewIds?: () => Iterable<string> | null;
2358
+ }
2359
+ /** Whether `entry` is a tool: a contribution that can hold focus, chosen from
2360
+ * a palette or held on a key. Only a tool's focus hooks (`initScratch`,
2361
+ * `cursor`, `onActivate`, the previews) ever run. */
2362
+ declare function isTool<TOverlay>(entry: Contribution<TOverlay>): entry is Tool<unknown, TOverlay>;
2363
+ /** Internal — which slot a tool occupies in the dispatch order. */
2364
+ type ToolSlot = 'hotkey' | 'active' | 'ambient';
2365
+ /** Internal alias for "a Tool of any scratch type" — used in registries and
2366
+ * dispatchers that hold tools of heterogeneous scratch shapes. `any` is
2367
+ * intentional: `Tool<TScratch>` is invariant in TScratch, so `Tool<unknown>`
2368
+ * is too strict for containers that accept any concrete `Tool<T>`. */
2369
+ type AnyTool = Tool<any, any>;
2370
+ /** "A tool of any scratch type that draws `TOverlay`" — the container form for
2371
+ * registries that hold heterogeneous scratch shapes but one overlay type. */
2372
+ type AnyToolOf<TOverlay> = Tool<any, TOverlay>;
2373
+
2067
2374
  /**
2068
2375
  * What a tool declares.
2069
2376
  *
@@ -2073,7 +2380,7 @@ declare function mergeContributions<T extends Contribution<unknown>>(...bundles:
2073
2380
  * definition is mostly `bindings`, plus presentation and any actions the tool
2074
2381
  * itself introduces.
2075
2382
  */
2076
- interface ToolDef<TScratch = void, TOverlay = unknown> {
2383
+ interface ToolDef<TScratch = void, TOverlay = KernelOverlay> {
2077
2384
  id: string;
2078
2385
  /** Capability tags for modality eligibility. `defineTool` forwards these
2079
2386
  * onto `Tool.eligibility.capabilities`, which is where every gate reads
@@ -2085,14 +2392,9 @@ interface ToolDef<TScratch = void, TOverlay = unknown> {
2085
2392
  * reference by id.
2086
2393
  *
2087
2394
  * Declared here rather than registered by the hook with `useAction`,
2088
- * because tool hooks run wherever the consumer calls them — for
2089
- * `<SceneCanvas>` that is ABOVE `<ActionsProviderIfRoot>`, where
2090
- * `useActionsRegistry()` returns null and `useAction` silently no-ops. The
2091
- * result was a binding pointing at an action id nothing had registered, so
2092
- * the gesture fell through to whatever matched next (polygon's
2093
- * wheel/arrow-key side adjustment did nothing and `nudge.*` moved the
2094
- * selection instead). `<ToolActionsMounter>` registers these from inside
2095
- * the provider.
2395
+ * because tool hooks run wherever the consumer calls them, which may be
2396
+ * outside any registry — and there `useAction` silently no-ops, leaving a
2397
+ * binding that points at an action id nothing registered.
2096
2398
  */
2097
2399
  actions?: Action[];
2098
2400
  /** Hook name as exported from the kit barrel (e.g. `'useHandTool'`).
@@ -2111,12 +2413,17 @@ interface ToolDef<TScratch = void, TOverlay = unknown> {
2111
2413
  * set so the invoker knows which tool to switch to). */
2112
2414
  keybinding?: ToolKeybinding;
2113
2415
  /** Held-key trigger: this tool engages while the key is down and
2114
- * disengages on release. Carried onto `Tool.eligibility.offhand`, which
2416
+ * disengages on release — a modifier, `'space'`, or any other key
2417
+ * (`'o'`), never while typing in a text field. Carried onto
2418
+ * `Tool.eligibility.offhand`, which
2115
2419
  * assembly reads to register the consolidated `tool.offhand` action — the
2116
2420
  * declaration is the wiring, with nothing for the host to do. */
2117
2421
  hotkey?: HotkeyTrigger;
2118
- onActivate?: (ctx: ToolCtx<TScratch>) => void;
2119
- onDeactivate?: (ctx: ToolCtx<TScratch>) => void;
2422
+ /** Fires as the tool becomes live in the active or hotkey slot. See
2423
+ * `Tool.onActivate`. */
2424
+ onActivate?: (ctx: ToolLifecycleCtx<TScratch>) => void;
2425
+ /** Fires as the tool stops being live in either slot. */
2426
+ onDeactivate?: (ctx: ToolLifecycleCtx<TScratch>) => void;
2120
2427
  cursor?: CursorSpec | ((ctx: ToolCtx<TScratch>) => CursorSpec);
2121
2428
  /** Override the default scratch initializer. Default is `() => null`
2122
2429
  * cast to `TScratch`, which works for tools whose scratch is fresh
@@ -2146,10 +2453,6 @@ interface ToolDef<TScratch = void, TOverlay = unknown> {
2146
2453
  * `'top'`, above everything. */
2147
2454
  overlayPosition?: OverlayPosition;
2148
2455
  }
2149
- /** Viewport-tool spec. Once phase tables went away this stopped differing
2150
- * from `ToolDef` in any structural way; `defineViewportTool` survives as the
2151
- * authoring signal that a tool pans/zooms the view rather than the scene. */
2152
- type ViewportToolDef<TScratch = void, TOverlay = unknown> = ToolDef<TScratch, TOverlay>;
2153
2456
 
2154
2457
  /**
2155
2458
  * Build a `Tool<TScratch>` from a declarative `ToolDef<TScratch>`.
@@ -2165,22 +2468,7 @@ type ViewportToolDef<TScratch = void, TOverlay = unknown> = ToolDef<TScratch, TO
2165
2468
  * and the `initScratch` / `cursor` defaults are worth applying uniformly, and
2166
2469
  * because `Tool.def` gives reflection a handle on the authored form.
2167
2470
  */
2168
- declare function defineTool<TScratch = void, TOverlay = unknown>(def: ToolDef<TScratch, TOverlay>): Tool<TScratch, TOverlay>;
2169
-
2170
- /**
2171
- * Define a tool that acts on the viewport rather than the scene.
2172
- *
2173
- * This used to do real work: `ViewportPhaseDef` was a narrowed `PhaseDef`
2174
- * (no click routes, drag restricted to the function form), and the factory
2175
- * lifted it back to the permissive shape before handing it to `defineTool`.
2176
- * With phase tables gone there is no shape left to narrow — a viewport tool
2177
- * declares `bindings` like any other, pointing at `viewport.*` actions.
2178
- *
2179
- * It survives as an authoring signal. `defineViewportTool` at the top of a
2180
- * hook says "this tool moves the camera, not the drawing", which is worth
2181
- * more than the type gymnastics it replaced.
2182
- */
2183
- declare function defineViewportTool<TScratch = void, TOverlay = unknown>(def: ViewportToolDef<TScratch, TOverlay>): Tool<TScratch, TOverlay>;
2471
+ declare function defineTool<TScratch = void, TOverlay = KernelOverlay>(def: ToolDef<TScratch, TOverlay>): Tool<TScratch, TOverlay>;
2184
2472
 
2185
2473
  /**
2186
2474
  * One row in the route registry — a single `GestureBinding` on one tool,
@@ -2191,7 +2479,8 @@ declare function defineViewportTool<TScratch = void, TOverlay = unknown>(def: Vi
2191
2479
  * declare them; consumers walk the list and group client-side.
2192
2480
  */
2193
2481
  interface RegistryEntry {
2194
- toolId: string;
2482
+ /** The id of the entry that declared the binding: a tool, or a contribution. */
2483
+ ownerId: string;
2195
2484
  /** Which phase the binding's `phase` spec restricts it to. `'any'` when it
2196
2485
  * declares none, declares `'*'`, or declares an atom list whose atoms
2197
2486
  * don't agree on one phase — such a binding fires in either phase, and
@@ -2224,6 +2513,9 @@ interface RegistryEntry {
2224
2513
  * specificity tuple, a `kindOf` predicate identity — read it here rather
2225
2514
  * than re-walking `Tool.bindings`. */
2226
2515
  spec: GestureSpec;
2516
+ /** The binding's `opts.views`: the views it is live in. Absent when it is
2517
+ * live in every view. */
2518
+ views?: readonly (string | null)[];
2227
2519
  }
2228
2520
  /**
2229
2521
  * Stand-in target for a `{ kindOf }` predicate spec.
@@ -2246,10 +2538,7 @@ declare const PREDICATE_TARGET = "predicate";
2246
2538
  * Registry, and reading `buildActionRegistry` next to `ActionsRegistry`
2247
2539
  * suggested otherwise.
2248
2540
  */
2249
- declare function buildRouteRegistry(tools: readonly Tool<unknown>[]): RegistryEntry[];
2250
- /** The route-grammar gesture a `GestureSpec.kind` routes as, or `undefined`
2251
- * for kinds the grammar has no name for. */
2252
- declare function routeGestureForSpecKind(kind: GestureSpec['kind']): GestureName | undefined;
2541
+ declare function buildRouteRegistry(entries: readonly Contribution<unknown>[]): RegistryEntry[];
2253
2542
  /**
2254
2543
  * Every route string one `GestureSpec` declares, in route-grammar notation.
2255
2544
  *
@@ -2273,9 +2562,9 @@ interface Conflict {
2273
2562
  arg: string | undefined;
2274
2563
  target: string | undefined;
2275
2564
  modifiers: ParsedModifiers;
2276
- /** All tool ids that registered the same tuple. At least 2 by
2277
- * construction. Order matches the input tools[] order. */
2278
- toolIds: string[];
2565
+ /** The ids of every entry that declared the same tuple. At least 2 by
2566
+ * construction, in input order. */
2567
+ ownerIds: string[];
2279
2568
  }
2280
2569
  /** Detect exact-tuple overlaps across a tool registration set.
2281
2570
  *
@@ -2299,6 +2588,12 @@ interface Conflict {
2299
2588
  * right way to be wrong here: this check has to be silent when nothing is
2300
2589
  * wrong or nobody will keep it on.
2301
2590
  *
2591
+ * - Two actions whose `eligible` rules can never hold together — each
2592
+ * mode's own Escape exit, say. See {@link rulesExclusive}.
2593
+ *
2594
+ * - Two bindings whose `opts.views` keep them from being live and equally
2595
+ * ranked in any one view. See {@link viewsTie}.
2596
+ *
2302
2597
  * Note that two bindings sharing a tuple on the SAME tool are now possible
2303
2598
  * (bindings are an array, where phase tables were objects with unique
2304
2599
  * keys) — so a conflict may name one tool twice.
@@ -2307,19 +2602,23 @@ interface Conflict {
2307
2602
  * over-reports, because registry tools take turns in the active slot and
2308
2603
  * can't collide with each other — see {@link findScopedConflicts}.
2309
2604
  */
2310
- declare function findConflicts(tools: readonly Tool<unknown>[]): Conflict[];
2605
+ declare function findConflicts(entries: readonly Contribution<unknown>[]): Conflict[];
2311
2606
  /**
2312
2607
  * The tool scopes as the dispatcher sees them — which is what decides whether
2313
2608
  * two same-tuple bindings can actually collide.
2314
2609
  */
2315
2610
  interface ToolScopes {
2316
2611
  /** Tools eligible for the active slot, keyed by id or as a flat list. */
2317
- registry: readonly Tool<unknown>[] | Readonly<Record<string, Tool<unknown>>>;
2318
- /** Always-on tools. Every one of these is live at once. */
2319
- ambient?: readonly Tool<unknown>[];
2612
+ registry: readonly Contribution<unknown>[] | Readonly<Record<string, Contribution<unknown>>>;
2613
+ /** Always-on entries. Every one of these is live at once. */
2614
+ ambient?: readonly Contribution<unknown>[];
2320
2615
  /** Registered actions. Their `defaultBinding`s assemble at ambient scope
2321
2616
  * (hotkey scope when `Action.scope` says so), alongside the tools above. */
2322
2617
  actions?: readonly Action[];
2618
+ /** Every mode the canvas can be in (`activeModeOf` builds one from a
2619
+ * definition). Two gated actions conflict only when some mode lets both
2620
+ * rules hold. Default: the kit's `DEFAULT_MODES`. */
2621
+ modes?: readonly ActiveMode[];
2323
2622
  }
2324
2623
  /**
2325
2624
  * Detect reachable route conflicts in an assembled tool set and report each one.
@@ -2536,5 +2835,25 @@ interface DepSchema {
2536
2835
  }
2537
2836
  /** Every dep name declared by anything that has merged into {@link DepSchema}. */
2538
2837
  type DepName = keyof DepSchema;
2838
+ /**
2839
+ * What the kernel drawing these tools takes as an overlay. Routing never reads
2840
+ * an overlay, so it has no type of its own for one; the kernel merges its type
2841
+ * in, the same way and for the same reason as {@link DepSchema}:
2842
+ *
2843
+ * ```ts
2844
+ * declare module '@weasel-js/routing' {
2845
+ * interface OverlaySchema { overlay: RenderLayer<unknown> }
2846
+ * }
2847
+ * ```
2848
+ *
2849
+ * `defineTool` defaults its overlay type to it, so a
2850
+ * tool it builds fits the kernel's `tools` without naming the parameter.
2851
+ */
2852
+ interface OverlaySchema {
2853
+ }
2854
+ /** The overlay type merged into {@link OverlaySchema}; `unknown` when none is. */
2855
+ type KernelOverlay = OverlaySchema extends {
2856
+ overlay: infer O;
2857
+ } ? O : unknown;
2539
2858
 
2540
- export { type HandleKind as $, type ActionsRegistry as A, type BindingOpts as B, type Contribution as C, type Dispatcher as D, type BuildRuleCtxArgs as E, COARSE_TARGET_SCALE as F, type Condition as G, type Conflict as H, type ContributionChrome as I, type ContributionDeps as J, type ContributionRouting as K, DEFAULT_ALLOWED_CAPABILITIES as L, DEFAULT_DEVICE_PROFILE as M, type DebugSink as N, type OverlayPosition as O, type DepName as P, type DetectedDeviceFacts as Q, type RuleCtx as R, type ScopedBinding as S, type Tool as T, type DeviceProfile as U, type DispatchLogEntry as V, type DispatcherContext as W, type DragSample as X, type Eligibility as Y, type EligibilityState as Z, type GestureBinding as _, type AffordanceHit as a, filterEligible as a$, type HitShape as a0, type HotkeyTrigger as a1, INGEST_STRING_MIMES as a2, type ImmediateInvoker as a3, type InvocationCtx as a4, type Invoker as a5, type KeyBinding as a6, type MatchResult as a7, type ModeSwitchLogEntry as a8, type ModifierState as a9, type ToolCtx as aA, type ToolDef as aB, type ToolKeybinding as aC, type ToolModifiers as aD, type ToolOffhandBindingSpec as aE, type ToolPresentation as aF, type ToolSlot as aG, type TraceLogEntry as aH, type UiOngoingControl as aI, type View as aJ, type ViewportToolDef as aK, actionBindings as aL, actionItems as aM, buildDepsFromRequires as aN, buildRouteRegistry as aO, buildRuleCtx as aP, buildToolOffhandBindings as aQ, clientToCanvas as aR, clientToCanvasRect as aS, createDispatcher as aT, defineTool as aU, defineViewportTool as aV, deleteScratch as aW, describeRule as aX, dlog as aY, evaluate as aZ, evaluateEnabled as a_, NEVER as aa, type NodeId as ab, type OngoingHandle as ac, type OngoingInvoker as ad, type OngoingOverlay as ae, type OverlayRole as af, PREDICATE_TARGET as ag, type Point2 as ah, type PointerSession as ai, type PointerSessionCallbacks as aj, type PointerSessionCancelReason as ak, type PointerSessionOptions as al, type RegistryEntry as am, type ResizeAnchor as an, type ResolveAllOptions as ao, type ResolveOnlyResult as ap, type ResolvedCandidate as aq, type Rule as ar, type ScratchKey as as, type ScratchStore as at, type SelectionApi as au, type SelectionExtendKey as av, type SelectionMode as aw, type Selector as ax, TOOL_OFFHAND_ID as ay, type ToolBounds as az, type DepSchema as b, findConflicts as b0, getScratch as b1, isAnchor as b2, isAnchorOrControl as b3, isBody as b4, isControlHandle as b5, isEditableTarget as b6, isEmpty as b7, isResizeHandle as b8, isRotateHandle as b9, validateActionId as bA, isSelectedBody as ba, isUnselectedBody as bb, itemsFromClipboardData as bc, itemsFromDataTransfer as bd, itemsFromFiles as be, liveScope as bf, makeToolOffhandAction as bg, matchBest as bh, matchSorted as bi, matchesKeyBinding as bj, mergeContributions as bk, offhandKeyFor as bl, openPointerSession as bm, pointerGestureId as bn, recordModeSwitch as bo, reportRouteConflicts as bp, resolveDeviceProfile as bq, resolveParams as br, routeGestureForSpecKind as bs, routesForSpec as bt, scopeBindings as bu, scratchKey as bv, setScratch as bw, specificity as bx, targetConsultsAffordance as by, validateActionDefaultBinding as bz, type Action as c, type AnyToolOf as d, type DepRegistry as e, DepRegistryProvider as f, useDepSource as g, useOptionalDepRegistry as h, ALWAYS as i, type ActionDeps as j, ActionDisabledReason as k, type ActionDispatch as l, type ActionEnabledResult as m, type ActionEntry as n, type ActionItem as o, type ActionPresentation as p, type ActionSource as q, type ActionVariant as r, type ActionsProp as s, type AnyTool as t, useDepRegistry as u, type BindingScope as v, type BindingSource as w, type BodyPredicate as x, type BoundGesture as y, type Bounds as z };
2859
+ export { type HandleKind as $, type ActionsRegistry as A, type BindingOpts as B, type Contribution as C, type Dispatcher as D, COARSE_TARGET_SCALE as E, type Condition as F, type Conflict as G, type ContributionChrome as H, type ContributionDeps as I, type ContributionRouting as J, type KernelOverlay as K, DEFAULT_ALLOWED_CAPABILITIES as L, DEFAULT_DEVICE_PROFILE as M, type DebugSink as N, type OverlayPosition as O, type DetectedDeviceFacts as P, type DeviceProfile as Q, type RuleCtx as R, ScopeNode as S, type DispatchRecord as T, type DispatchRecordInput as U, type DispatcherContext as V, type DragSample as W, type DroppedCandidate as X, type Eligibility as Y, type EligibilityState as Z, type GestureBinding as _, type AffordanceHit as a, clientToCanvasRect as a$, type HitShape as a0, type HotkeyTrigger as a1, INGEST_STRING_MIMES as a2, type ImmediateInvoker as a3, type InvocationCtx as a4, type Invoker as a5, type KeyBinding as a6, type MatchOutcome as a7, type MatchResult as a8, type ModeShortcutHandlers as a9, type SelectionApi as aA, type SelectionExtendKey as aB, type SelectionMode as aC, type Selector as aD, type SpecificityPart as aE, TOOL_OFFHAND_ID as aF, type Tool as aG, type ToolBounds as aH, type ToolCtx as aI, type ToolDef as aJ, type ToolKeybinding as aK, type ToolLifecycleCtx as aL, type ToolModifiers as aM, type ToolOffhandBindingSpec as aN, type ToolPresentation as aO, type ToolSlot as aP, type TraceLogEntry as aQ, type UiOngoingControl as aR, type View as aS, type WalkStep as aT, actionBindings as aU, actionItems as aV, buildDepsFromRequires as aW, buildRouteRegistry as aX, buildRuleCtx as aY, buildToolOffhandBindings as aZ, clientToCanvas as a_, type ModeSwitchLogEntry as aa, type ModifierState as ab, NEVER as ac, type NodeId as ad, type OngoingHandle as ae, type OngoingInvoker as af, type OngoingOverlay as ag, type OverlayRole as ah, type OverlaySchema as ai, PREDICATE_TARGET as aj, type PlacedBy as ak, type Point2 as al, type PointerSession as am, type PointerSessionCallbacks as an, type PointerSessionCancelReason as ao, type PointerSessionOptions as ap, type RankedCandidate as aq, type RecordCandidate as ar, type RegistryEntry as as, type ResizeAnchor as at, type ResolveAllOptions as au, type ResolveOnlyResult as av, type ResolvedCandidate as aw, type Rule as ax, type ScratchKey as ay, type ScratchStore as az, type DepSchema as b, createDepRegistry as b0, createDispatcher as b1, defineTool as b2, deleteScratch as b3, describeRule as b4, dlog as b5, evaluate as b6, evaluateEnabled as b7, filterEligible as b8, findConflicts as b9, pointerGestureId as bA, preferContextual as bB, publishLiveDispatch as bC, recordModeSwitch as bD, reportRouteConflicts as bE, resolveDeviceProfile as bF, resolveParams as bG, routesForSpec as bH, ruleCanHoldIn as bI, rulesExclusive as bJ, scopeBindings as bK, scratchKey as bL, setScratch as bM, targetConsultsAffordance as bN, validateActionDefaultBinding as bO, validateActionId as bP, getScratch as ba, isAnchor as bb, isAnchorOrControl as bc, isBody as bd, isControlHandle as be, isEditableTarget as bf, isEmpty as bg, isResizeHandle as bh, isRotateHandle as bi, isSelectedBody as bj, isTool as bk, isUnselectedBody as bl, itemsFromClipboardData as bm, itemsFromDataTransfer as bn, itemsFromFiles as bo, liveScope as bp, makeToolOffhandAction as bq, matchBest as br, matchSorted as bs, matchSortedWithBarred as bt, matchesKeyBinding as bu, mergeContributions as bv, modeShortcutSpec as bw, modeShortcuts as bx, offhandKeyFor as by, openPointerSession as bz, type DepRegistry as c, type DepName as d, type Action as e, DepNode as f, type AnyToolOf as g, type ScopedBinding as h, ALWAYS as i, type ActionDeps as j, ActionDisabledReason as k, type ActionDispatch as l, type ActionEnabledResult as m, type ActionEntry as n, type ActionItem as o, type ActionPresentation as p, type ActionSource as q, type ActionVariant as r, type ActionsProp as s, type AnyTool as t, type BindingScope as u, type BindingSource as v, type BodyPredicate as w, type BoundGesture as x, type Bounds as y, type BuildRuleCtxArgs as z };