JEPA4Japan · tutorials

Chapter 9: Tools Are Traffic Lights, Not a Bag of Booleans

3,411 words 16 min read #Canvas#Frontend Engineering#Infinite Canvas#ELI5

Use explicit states, guards, cancel, commit, and commands to implement Select, Hand, Rectangle, Freehand, and Eraser tools.

Course progress Course outline 18 of 18 lessons available

Part I: Choose the Surface Before You Draw—Product, Pixels, and Coordinates

  1. 01 Chapter 1: Do Not Draw Yet—Canvas Is Not a Product Architecture available now
  2. 02 Chapter 2: A Sheet of Pixels That Forgets available now
  3. 03 Chapter 3: Turn Drawing into a Replayable Recipe available now
  4. 04 Chapter 4: Four Maps and a Camera available now

Part II: Give the Pixel World a Brain—Model, Scheduling, Input, and Tools

  1. 05 Chapter 5: Give the Pixel World a Registry available now
  2. 06 Chapter 6: Redraw Only When the Light Turns On—Render Scheduling and the React Boundary available now
  3. 07 Chapter 7: Mouse, Touch, and Pen Speak One Language available now
  4. 08 Chapter 8: Find the Big Box Before Inspecting the Edge available now
  5. 09 Chapter 9: Tools Are Traffic Lights, Not a Bag of Booleans Current lesson

Part III: From “It Drags” to “It Is Trustworthy”—Interaction, Text, Assets, and Recovery

  1. 10 Chapter 10: Make the Editor Feel Right available now
  2. 11 Chapter 11: Drawn Text Is Not Editable Text available now
  3. 12 Chapter 12: Borrowed Images Cannot Be Packed Without Rules available now
  4. 13 Chapter 13: Time Machines and Old Boxes available now
  5. 14 Chapter 14: Looking Correct Is Not Being Correct available now

Part IV: Master-Level Decisions—Performance, Workers, GPU, SDKs, Collaboration, and AI

  1. 15 Chapter 15: Do Not Search Ten Thousand Children One by One available now
  2. 16 Chapter 16: Keep the Front Desk Out of the Kitchen—Worker and GPU Upgrades available now
  3. 17 Chapter 17: Build the Car or Buy a Proven Chassis? available now
  4. 18 Chapter 18: People and AI Edit the Same Ledger available now

Start with a game a five-year-old can understand

The one truth in this chapter: interaction is a sequence of state transitions, not a scatter of event callbacks.

Make a traffic light from cardboard and get a toy car. The car may move on green, must stop on red, and may only finish crossing on yellow. Whenever the light changes, the child first puts away the previous rule card and then displays the next one.

Predict what happens first. If you attach four sticky notes at the same time—“moving,” “maybe about to move,” “stopping,” and “turning”—can the child give one answer when the teacher asks, “What does Escape do now?” If the green light is unplugged midway, should the car’s position be written into the city map or restored to the starting line?

  1. Exactly one active stateone rule applies now
  2. Receive one notepress, move, cancel
  3. Take a named roada Guard decides whether it is allowed
  4. Record it only at the finishCommit or Cancel
Decide first: while the car is making a trial run, should the map already contain dozens of intermediate positions? No. Those are only temporary arrangements on the table.

The traffic light is not decoration. It gives every input note exactly one legal recipient. Idle → Pointing → Dragging → Idle is a named road. The machine does not enter Dragging until movement exceeds the threshold. Escape, Pointer Cancel, and switching Tools all follow a cancellation road back. Only Commit produces a Command that can be undone.

Translate the toys into Canvas

Toy traffic gameCanvas LabExplicit meaning
Currently lit signalInteraction StateIdle, Pointing, Dragging, Resizing, Rotating, Editing
Kind of vehicleToolSelect, Hand, Rectangle, Freehand, Eraser
Delivered noteNormalized Input EventThe unified Chapter 7 event, with no browser branches
Named roadTransition(state,event) → next state + effects
Gatekeeper at an intersectionGuardDistance, permission, object existence, and other conditions
Put away the card before changing lightsExit actionRelease Capture ownership and clear Preview
Display the card after changing lightsEntry actionCreate Preview and set Cursor
Trial arrangement on the tableTransient PreviewBelongs to neither Document nor History
Write into the city mapCommit/CommandOne atomic, undoable transaction
Return to the starting lineCancelDiscard Preview and produce no Command

The analogy has limits. A state machine does not decide product rules for you, and red-yellow-green cannot express substates within text editing. Complex tools can therefore use a Hierarchical State Machine: select.transforming.resizing shares transforming’s cancellation rules while keeping exactly one leaf state active. A state machine also does not mean putting all application state into one giant enum; Document, Session, and Presence remain separate data.

Kill the wrong instincts first

  • “A few booleans are simpler.” Five booleans have 32 combinations, most of them meaningless. You cannot prove whether isDragging && isResizing sends Move to dragging or resizing.
  • “Changing the Shape directly inside Pointer callbacks feels most responsive.” One Drag creates hundreds of History entries, Cancel cannot restore the original, and collaborators see partial work.
  • “Every Tool should listen to the DOM itself.” Input ownership becomes ambiguous, and an old listener can still commit after switching Tools.
  • “If there is no pointerup, reset on the next click.” That next click can accidentally commit the old transaction. Cancel, Lost Capture, and Blur must be first-class events.
  • “Undo can pop History normally during a temporary Drag.” The baseline Document changes while Preview still references the old object. Cancel first or define an explicit nested-transaction policy.
  • “Dispatch the next event recursively inside a state transition.” Reentrancy can transition again before Exit finishes. The Engine should process a serial queue.

Production backpack

Prerequisite contract

Chapter 7 emits only PointerStarted/Moved/Ended/Canceled, Wheel, Key, and Gesture events. Chapter 8 returns a stable HitResult. The Document accepts only Commands, while Preview Store holds only temporary derived geometry. Every Tool runs in Node tests without React, DOM, or Renderer.

Formal knowledge

A Finite-State Machine has a finite set of states, events, and explicit Transitions. A Hierarchical State Machine lets substates share Entry, Exit, and Cancel behavior instead of duplicating code. Tool is the interaction mode selected for the longer term; Interaction State describes what that Tool is doing right now. Do not combine them into a string such as tool='select-dragging-rotating'.

The basic Select Tool path is Idle → Pointing → Dragging → Commit. A resize-handle hit enters Resizing, a rotation-handle hit enters Rotating, and double-clicking Text enters Editing. Pointing stores the origin and HitResult. Only a Guard that sees screen distance cross the Drag Threshold enters Dragging. Hand Tool writes movement to Camera Session rather than Document History. Pan must use the delta between adjacent Screen Points; calculating delta from World Points again after every Camera change creates feedback drift. Rectangle Tool creates a rectangle Preview from an anchor. Freehand Tool collects the coalesced points from Chapter 7 and resamples them in a later chapter. Eraser Tool collects stable IDs that it hits, but creates one Batch Delete only at Commit.

A Transition is determined by the event, current state, and read-only context. Entry creates a Preview or Transaction token; Exit guarantees cleanup. Escape, Pointer Cancel, Tool Switch, and deletion of the object by a collaborator all require explicit Transitions. Guards include whether the current pointerId owns the interaction, whether permission allows the action, whether the Shape still exists, whether the threshold has been crossed, and whether the matrix is invertible.

Transient Preview draws only “what would happen if you released now.” It is not serialized, synchronized, or added to Undo. Commit turns the difference between Preview and baseline values into one Command; Cancel discards it. The Command Boundary is the only domain write entry point. A continuous Drag coalesces into one Transaction. An operation over multiple Shapes commits one Batch so Undo remains a single step.

Reentrancy means dispatching another event before a Transition finishes. The Engine processes events serially with a FIFO queue and a processing latch. An effect may enqueue another event but may not recursively mutate the current state. Log {tool,from,event,to,transactionId} for every transition; production logs must not record sensitive text or complete coordinate trails.

Evidence and compatibility (verified 2026-08-29)

The state machine itself is browser-independent. Sources of browser termination still follow MDN pointercancel, lostpointercapture, and the Page Visibility API. Keyboard repeat and KeyboardEvent.code require explicit policies: Escape can ignore repeats, while continuous movement with arrow keys can accept them. A DOM event must not become part of the state type, or Node Replay loses portability.

The engineering increment for this chapter

Starting point: A collection of Pointer callbacks and isDragging/isResizing/isDrawing/isMaybeDragging/isAboutToResize. Finish line: Every event identifies the Tool, State, and Transition that handles it, and writes occur only through a Commit Command.

Add these files:

  • src/engine/tools/ToolMachine.ts: serial dispatch, Entry/Exit, and trace;
  • src/engine/tools/states.ts: discriminated unions that prohibit implicit combinations;
  • src/engine/tools/{Select,Hand,Rectangle,Freehand,Eraser}Tool.ts: tool transition tables;
  • src/engine/preview/PreviewStore.ts: transient state;
  • src/engine/commands/CommandBus.ts: atomic commit;
  • src/engine/tools/__tests__/replay.test.ts: event sequences and transition assertions.

First define states that cannot be true simultaneously and the effects protocol:

type Point = Readonly<{ x: number; y: number }>;
type ToolId = 'select' | 'hand' | 'rectangle' | 'freehand' | 'eraser';

export type ToolState =
  | Readonly<{ kind: 'idle'; tool: ToolId }>
  | Readonly<{
      kind: 'pointing';
      tool: 'select';
      pointerId: number;
      originScreen: Point;
      originWorld: Point;
      hitId: string | null;
      transactionId: string;
    }>
  | Readonly<{
      kind: 'dragging';
      tool: 'select';
      pointerId: number;
      originScreen: Point;
      originWorld: Point;
      current: Point;
      hitId: string;
      transactionId: string;
    }>
  | Readonly<{
      kind: 'resizing';
      tool: 'select';
      pointerId: number;
      shapeIds: readonly string[];
      handle: string;
      origin: Point;
      current: Point;
      transactionId: string;
    }>
  | Readonly<{
      kind: 'rotating';
      tool: 'select';
      pointerId: number;
      shapeIds: readonly string[];
      pivot: Point;
      current: Point;
      transactionId: string;
    }>
  | Readonly<{ kind: 'editing'; tool: 'select'; shapeId: string; transactionId: string }>
  | Readonly<{
      kind: 'panning';
      tool: 'hand';
      pointerId: number;
      originScreen: Point;
      currentScreen: Point;
    }>
  | Readonly<{
      kind: 'drawing-rectangle';
      tool: 'rectangle';
      pointerId: number;
      anchor: Point;
      current: Point;
      transactionId: string;
    }>
  | Readonly<{
      kind: 'drawing-freehand';
      tool: 'freehand';
      pointerId: number;
      points: readonly Point[];
      transactionId: string;
    }>
  | Readonly<{
      kind: 'erasing';
      tool: 'eraser';
      pointerId: number;
      ids: ReadonlySet<string>;
      transactionId: string;
    }>;

export type ToolEvent =
  | Readonly<{ kind: 'down'; pointerId: number; world: Point; screen: Point; hitId: string | null }>
  | Readonly<{ kind: 'move'; pointerId: number; world: Point; screen: Point }>
  | Readonly<{ kind: 'up'; pointerId: number; world: Point }>
  | Readonly<{ kind: 'cancel'; pointerId?: number; reason: string }>
  | Readonly<{ kind: 'switch'; tool: ToolId }>
  | Readonly<{ kind: 'escape' }>
  | Readonly<{ kind: 'object-deleted'; id: string }>
  | Readonly<{ kind: 'undo-requested' }>;

export type Effect =
  | Readonly<{ kind: 'preview-move'; id: string; from: Point; to: Point }>
  | Readonly<{ kind: 'preview-rectangle'; anchor: Point; current: Point }>
  | Readonly<{ kind: 'preview-freehand'; points: readonly Point[] }>
  | Readonly<{ kind: 'preview-erase'; ids: ReadonlySet<string> }>
  | Readonly<{ kind: 'pan-camera'; from: Point; to: Point }>
  | Readonly<{ kind: 'commit'; transactionId: string; command: Command }>
  | Readonly<{ kind: 'cancel-preview'; transactionId?: string }>;

export type Command =
  | Readonly<{ type: 'MoveShape'; id: string; from: Point; to: Point }>
  | Readonly<{ type: 'CreateRectangle'; anchor: Point; opposite: Point }>
  | Readonly<{ type: 'CreateFreehand'; points: readonly Point[] }>
  | Readonly<{ type: 'DeleteShapes'; ids: readonly string[] }>;

The reducer below completely covers the basic paths, cancellation, switching, and Undo boundary for all five tools in this chapter. hitAt comes from GeometryKernel:

type Result = Readonly<{ state: ToolState; effects: readonly Effect[] }>;
type Context = Readonly<{
  dragThresholdPx: number;
  exists(id: string): boolean;
  hitAt(point: Point): string | null;
  transactionId(): string;
}>;

const idle = (tool: ToolId): ToolState => ({ kind: 'idle', tool });
const distance = (a: Point, b: Point) => Math.hypot(a.x - b.x, a.y - b.y);
const cancel = (state: ToolState, tool = state.tool): Result => ({
  state: idle(tool),
  effects:
    state.kind === 'idle' || state.kind === 'panning'
      ? []
      : [
          {
            kind: 'cancel-preview',
            transactionId: 'transactionId' in state ? state.transactionId : undefined,
          },
        ],
});

export function transition(state: ToolState, event: ToolEvent, ctx: Context): Result {
  if (event.kind === 'switch') return cancel(state, event.tool);
  if (event.kind === 'escape') return cancel(state);
  if (event.kind === 'cancel') {
    if (
      event.pointerId !== undefined &&
      'pointerId' in state &&
      event.pointerId !== state.pointerId
    )
      return { state, effects: [] };
    return cancel(state);
  }
  if (event.kind === 'undo-requested' && state.kind !== 'idle') return cancel(state);
  if (event.kind === 'object-deleted' && 'shapeId' in state && state.shapeId === event.id)
    return cancel(state);
  if (event.kind === 'object-deleted' && 'hitId' in state && state.hitId === event.id)
    return cancel(state);
  if (event.kind === 'object-deleted' && 'shapeIds' in state && state.shapeIds.includes(event.id))
    return cancel(state);

  if (state.kind === 'idle' && event.kind === 'down') {
    if (state.tool === 'select')
      return {
        state: {
          kind: 'pointing',
          tool: 'select',
          pointerId: event.pointerId,
          originScreen: event.screen,
          originWorld: event.world,
          hitId: event.hitId,
          transactionId: ctx.transactionId(),
        },
        effects: [],
      };
    if (state.tool === 'hand')
      return {
        state: {
          kind: 'panning',
          tool: 'hand',
          pointerId: event.pointerId,
          originScreen: event.screen,
          currentScreen: event.screen,
        },
        effects: [],
      };
    if (state.tool === 'rectangle')
      return {
        state: {
          kind: 'drawing-rectangle',
          tool: 'rectangle',
          pointerId: event.pointerId,
          anchor: event.world,
          current: event.world,
          transactionId: ctx.transactionId(),
        },
        effects: [{ kind: 'preview-rectangle', anchor: event.world, current: event.world }],
      };
    if (state.tool === 'freehand')
      return {
        state: {
          kind: 'drawing-freehand',
          tool: 'freehand',
          pointerId: event.pointerId,
          points: [event.world],
          transactionId: ctx.transactionId(),
        },
        effects: [{ kind: 'preview-freehand', points: [event.world] }],
      };
    return {
      state: {
        kind: 'erasing',
        tool: 'eraser',
        pointerId: event.pointerId,
        ids: new Set(event.hitId ? [event.hitId] : []),
        transactionId: ctx.transactionId(),
      },
      effects: [],
    };
  }

  if (state.kind === 'pointing' && event.kind === 'move' && event.pointerId === state.pointerId) {
    if (
      !state.hitId ||
      !ctx.exists(state.hitId) ||
      distance(state.originScreen, event.screen) < ctx.dragThresholdPx
    )
      return { state, effects: [] };
    const next: ToolState = {
      kind: 'dragging',
      tool: 'select',
      pointerId: state.pointerId,
      originScreen: state.originScreen,
      originWorld: state.originWorld,
      current: event.world,
      hitId: state.hitId,
      transactionId: state.transactionId,
    };
    return {
      state: next,
      effects: [
        { kind: 'preview-move', id: state.hitId, from: state.originWorld, to: event.world },
      ],
    };
  }
  if (state.kind === 'dragging' && event.kind === 'move' && event.pointerId === state.pointerId) {
    return {
      state: { ...state, current: event.world },
      effects: [
        { kind: 'preview-move', id: state.hitId, from: state.originWorld, to: event.world },
      ],
    };
  }
  if (state.kind === 'dragging' && event.kind === 'up' && event.pointerId === state.pointerId) {
    return {
      state: idle('select'),
      effects: [
        {
          kind: 'commit',
          transactionId: state.transactionId,
          command: { type: 'MoveShape', id: state.hitId, from: state.originWorld, to: event.world },
        },
      ],
    };
  }
  if (state.kind === 'pointing' && event.kind === 'up' && event.pointerId === state.pointerId)
    return { state: idle('select'), effects: [] };

  if (state.kind === 'panning' && event.kind === 'move' && event.pointerId === state.pointerId)
    return {
      state: { ...state, currentScreen: event.screen },
      effects: [{ kind: 'pan-camera', from: state.currentScreen, to: event.screen }],
    };
  if (state.kind === 'panning' && event.kind === 'up' && event.pointerId === state.pointerId)
    return { state: idle('hand'), effects: [] };

  if (
    state.kind === 'drawing-rectangle' &&
    event.kind === 'move' &&
    event.pointerId === state.pointerId
  )
    return {
      state: { ...state, current: event.world },
      effects: [{ kind: 'preview-rectangle', anchor: state.anchor, current: event.world }],
    };
  if (
    state.kind === 'drawing-rectangle' &&
    event.kind === 'up' &&
    event.pointerId === state.pointerId
  )
    return {
      state: idle('rectangle'),
      effects: [
        {
          kind: 'commit',
          transactionId: state.transactionId,
          command: { type: 'CreateRectangle', anchor: state.anchor, opposite: event.world },
        },
      ],
    };

  if (
    state.kind === 'drawing-freehand' &&
    event.kind === 'move' &&
    event.pointerId === state.pointerId
  ) {
    const points = [...state.points, event.world];
    return { state: { ...state, points }, effects: [{ kind: 'preview-freehand', points }] };
  }
  if (
    state.kind === 'drawing-freehand' &&
    event.kind === 'up' &&
    event.pointerId === state.pointerId
  ) {
    const last = state.points[state.points.length - 1];
    const points =
      last && last.x === event.world.x && last.y === event.world.y
        ? state.points
        : [...state.points, event.world];
    return {
      state: idle('freehand'),
      effects: [
        {
          kind: 'commit',
          transactionId: state.transactionId,
          command: { type: 'CreateFreehand', points },
        },
      ],
    };
  }

  if (state.kind === 'erasing' && event.kind === 'move' && event.pointerId === state.pointerId) {
    const id = ctx.hitAt(event.world),
      ids = new Set(state.ids);
    if (id) ids.add(id);
    return { state: { ...state, ids }, effects: [{ kind: 'preview-erase', ids }] };
  }
  if (state.kind === 'erasing' && event.kind === 'up' && event.pointerId === state.pointerId) {
    const ids = new Set(state.ids),
      id = ctx.hitAt(event.world);
    if (id) ids.add(id);
    return {
      state: idle('eraser'),
      effects: [
        {
          kind: 'commit',
          transactionId: state.transactionId,
          command: { type: 'DeleteShapes', ids: [...ids] },
        },
      ],
    };
  }

  return { state, effects: [] };
}

export type TransitionTrace = Readonly<{
  tool: ToolId;
  from: ToolState['kind'];
  event: ToolEvent['kind'];
  to: ToolState['kind'];
  transactionId?: string;
}>;

export class ToolMachine {
  private state: ToolState = idle('select');
  private queue: ToolEvent[] = [];
  private processing = false;
  constructor(
    private readonly context: Context,
    private readonly run: (effect: Effect) => void,
    private readonly trace: (entry: TransitionTrace) => void = () => undefined,
  ) {}
  dispatch(event: ToolEvent) {
    this.queue.push(event);
    if (this.processing) return;
    this.processing = true;
    try {
      while (this.queue.length) {
        const event = this.queue.shift()!;
        const from = this.state;
        const result = transition(from, event, this.context);
        this.state = result.state;
        const transactionId =
          'transactionId' in from
            ? from.transactionId
            : 'transactionId' in result.state
              ? result.state.transactionId
              : undefined;
        this.trace({
          tool: from.tool,
          from: from.kind,
          event: event.kind,
          to: result.state.kind,
          transactionId,
        });
        for (const effect of result.effects) this.run(effect);
      }
    } finally {
      this.processing = false;
    }
  }
  snapshot() {
    return this.state;
  }
}

Resize, Rotate, and Editing use the same protocol. Their Entry freezes the original Selection Snapshot, Move changes only Preview, and Commit produces one Batch. If an object is deleted or a matrix becomes non-invertible, they Cancel. Do not push them back into Dragging as boolean fields.

Tests prove paths through event replay rather than mocking implementation details:

import { describe, expect, it } from 'vitest';
import { ToolMachine } from '../ToolMachine';

describe('ToolMachine replay', () => {
  it('drags after crossing the threshold, and Escape clears only preview without committing a Command', () => {
    const effects: Array<{ kind: string }> = [];
    let sequence = 0;
    const machine = new ToolMachine(
      {
        dragThresholdPx: 4,
        exists: () => true,
        hitAt: () => 'shape-1',
        transactionId: () => `tx-${++sequence}`,
      },
      (e) => effects.push(e),
    );
    machine.dispatch({
      kind: 'down',
      pointerId: 3,
      screen: { x: 0, y: 0 },
      world: { x: 0, y: 0 },
      hitId: 'shape-1',
    });
    machine.dispatch({
      kind: 'move',
      pointerId: 3,
      screen: { x: 10, y: 0 },
      world: { x: 10, y: 0 },
    });
    machine.dispatch({ kind: 'escape' });

    expect(machine.snapshot()).toEqual({ kind: 'idle', tool: 'select' });
    expect(effects.map((e) => e.kind)).toEqual(['preview-move', 'cancel-preview']);
    expect(effects.some((e) => e.kind === 'commit')).toBe(false);
  });

  it('up or cancel from a second pointer cannot commit or cancel the first pointer transaction', () => {
    const effects: Array<{ kind: string }> = [];
    const machine = new ToolMachine(
      {
        dragThresholdPx: 4,
        exists: () => true,
        hitAt: () => 'shape-1',
        transactionId: () => 'tx-owner',
      },
      (e) => effects.push(e),
    );
    machine.dispatch({
      kind: 'down',
      pointerId: 1,
      screen: { x: 0, y: 0 },
      world: { x: 0, y: 0 },
      hitId: 'shape-1',
    });
    machine.dispatch({
      kind: 'move',
      pointerId: 1,
      screen: { x: 10, y: 0 },
      world: { x: 10, y: 0 },
    });
    machine.dispatch({ kind: 'up', pointerId: 2, world: { x: 99, y: 99 } });
    machine.dispatch({ kind: 'cancel', pointerId: 2, reason: 'other pointer canceled' });

    expect(machine.snapshot().kind).toBe('dragging');
    expect(effects.some((e) => e.kind === 'commit' || e.kind === 'cancel-preview')).toBe(false);
  });
});

Run pnpm vitest run src/engine/tools. Transition replay should be entirely green, and the coverage report should show the Cancel path for every state executing. Run pnpm playwright test tests/browser/tool-cancel.spec.ts. Escape, focus loss, Pointer Cancel, and switching Tools should all restore Idle without increasing the Document revision.

Return to the traffic light. State is not a pile of sticky notes; it is the one light that is currently on. Preview is the car’s position on the practice field. Command is the new record on the city map. Given any event, we can point to the light, the road, and whether it records anything.

Break it on purpose

InjectionSymptomEvidenceFixRegression testRecovery
Press Escape during DragShape stops halfway or is committedTransition trace and revisionCancel Preview; emit no CommandReplay down/move/escapeRedraw from baseline snapshot
Switch Tool during DragTwo Tools respond at onceOld state is still not IdleSwitch follows Exit/Cancel firstSwitch between every Tool pairClear input ownership
Send Pointer CancelStuck in Dragging foreverTrace has no cancellation edgeEvery active state has CancelCancel table testDiscard transaction token
Delete object during ResizeNull reference or resurrected objectexists guard failsobject-deleted → cancelCollaborative-delete sequenceRefresh Selection
Request Undo during PreviewBaseline and Preview divergeHistory revision changesCancel first, then UndoNested event sequenceRecompute derived state
Double Click conflicts with Drag thresholdBoth edits and movesTwo transitions firePointing Guard selects one branchBoundary time/distance parametersCancel text Overlay
Never send Pointer UpState remains stuckEvent Replay ends outside IdleCancel/Lost Capture/Blur watchdogMissing-Up testEngine cancels explicitly

Pass with evidence

QuestionEvidence you must identify
Who handles the current event?tool + state + event + transition trace
When is the Document written?The sole commit effect and CommandBus log
Is Cancel clean?Unchanged revision, empty Preview, final state Idle
How many Undo steps does Continuous Drag produce?One transactionId and one Command
Is reentrancy possible?Effects that dispatch during a FIFO test remain strictly ordered
Are states exhaustive?TypeScript never checks and transition-table coverage
Test surfaceAutomated evidenceManual evidence
One TransitionEvent Replay and state snapshotDevTools trace identifies Tool/State/Transition
Cancel does not commitRevision, Command-count, and Preview assertionsExercise Escape, focus loss, and system cancellation
One input ownerTool-switch and reentrancy sequence testsAlternate tools quickly and observe no double response
  • Select, Hand, Rectangle, Freehand, and Eraser each have explicit Idle/Active/Commit/Cancel paths.
  • Resizing, Rotating, and Editing are explicit states, not booleans.
  • Guard, Entry, Exit, Transient Preview, and Transaction Boundary have unit tests.
  • Escape, Tool Switch, Pointer Cancel, object deletion, Undo, and missing Up all have deterministic results.
  • A Tool does not listen to the DOM or modify the Document directly.
  • For any event, you can answer “which Tool, which State, which Transition.”

Explain it to a five-year-old

Answer without using “state machine,” “transaction,” “command,” or “reentrancy”:

  1. Why can a traffic light clearly display only one rule at a time?
  2. When the teacher stops the car on the practice field, why can the halfway point not be written into the city map?
  3. Why must you put away the previous car’s rule card before switching cars?
  4. New question: if another note arrives before the car settles, how does the manager stop the two notes from cutting in front of each other?
Show the reference answer With only one rule active, every action has one answer; many simultaneous sticky notes create combinations that contradict each other. The practice field is only a trial: record one entry on the map after reaching the finish, or return to the starting line when stopped. Put away the old card before switching cars so the old car cannot keep following new instructions. The manager queues new notes and takes the next only after handling one note and tidying the table, so nothing cuts in halfway through a light change.