Spotter

Spotter is the explicit "pick an element" picker. The user moves the mouse, dddk frames whatever they hover, they click to lock, or they drag to lasso a region. The agent then knows exactly which DOM node(s) the user is talking about.

DotDotDuck constructs a Spotter instance for you, but enableRing: false is the default — Spotter doesn't render anything until you start it.

import { DotDotDuck } from '@perhapxin/dddk';

const dddk = new DotDotDuck({ /* ... */ });
dddk.mount();

// Turn on the hover ring (e.g. when the user picks "select an element"):
dddk.spotter.start();

// When done:
dddk.spotter.destroy();

The three states

State What the user sees How it ends
idle Nothing. —
ring Pink animated frame around the hovered element. Mouse moves away (ring disappears) or click to lock.
lock Frame pinned around a chosen element. Host calls unlock() or user starts a new action.
lasso Dashed rect being drawn under the cursor. Mouse-up commits the region; <5px wide cancels.

State transitions:

idle ─(hover 200ms)→ ring ─(click)→ lock ─(unlock)→ idle
  └─(beginLasso)→ lasso ─(endLasso)→ idle

Ring shows after a 200ms hover dwell so the screen doesn't flash a frame around every paragraph the user pauses on. ringDelay is configurable.


Constructor: SpotterOptions

field type default what it does
ringDelay number 200 ms of hover required before the ring appears.
enableRing boolean true Hover-to-frame. DotDotDuck overrides this to false.
enableLasso boolean true Drag-to-region.
ignoreSelector string — CSS selector for elements Spotter must never frame. dddk passes [data-dddk-ui] so the palette / subtitle don't trigger it.
onRingShow (info: { selector, element, rect }) => void — Fires every time the ring appears around a new element.
onRingHide () => void — Fires when the ring goes away.
onLock (info: { selector, element }) => void — Fires when the user pins the current ring as a lock.
onLassoComplete (info: { elements, bounds }) => void — Fires on mouse-up after a valid lasso drag.

The orchestrator's dddk.spotter is wired to forward all four callbacks as dddk events (pointer_ring_show, pointer_ring_hide, pointer_lock, pointer_lasso) — so for most cases you just listen on dddk.on(...) instead of constructing your own.


Direct API

spotter.start();              // attach the mousemove listener
spotter.destroy();            // detach + clear ring + cancel lasso
spotter.getState();           // 'idle' | 'ring' | 'lock' | 'lasso'

spotter.lockCurrent();        // pin whatever's currently in ring; returns
                              // { selector, element } or null

spotter.beginLasso(x, y);     // explicit start (use from a hotkey)
spotter.updateLasso(x, y);    // usually wired automatically via mousemove
spotter.endLasso(commit);     // commit:true fires onLassoComplete

spotter.unlock();             // drop the lock, return to idle

lockCurrent() is typically called from GestureManager.onVoiceStart so that long-press-while-hovering pins the hovered element for the agent.


Wiring it into your app

Scenario A — "Pick an element" mode

User clicks a toolbar button → ring starts → user clicks an element → you do something with the selector:

import type { SpotterOptions } from '@perhapxin/dddk';

function startElementPick() {
  dddk.spotter.start();
  const off = dddk.on('pointer_lock', ({ selector }) => {
    off();
    dddk.spotter.destroy();
    dddk.startAgent(`Tell me about this element.`, {
      selection: { element: selector },
    });
  });
}

Scenario B — Lasso a region for the agent

function startLasso() {
  dddk.spotter.start();
  // Custom hotkey starts the lasso at the cursor (your code captures cursor pos).
  document.addEventListener('mousedown', (e) => {
    if (!e.shiftKey) return;
    dddk.spotter.beginLasso(e.pageX, e.pageY);
    const upOnce = (ev: MouseEvent) => {
      dddk.spotter.endLasso(true);
      document.removeEventListener('mouseup', upOnce);
    };
    document.addEventListener('mouseup', upOnce);
  });

  dddk.on('pointer_lasso', ({ elements, bounds }) => {
    const selectors = elements.map((el) => el.tagName.toLowerCase()).join(', ');
    dddk.startAgent(`The user circled this region: ${selectors} (${bounds.width}×${bounds.height}).`);
  });
}

pointer_lasso carries the list of fully-contained HTMLElements and the bounding DOMRect. The list is currently a full-DOM O(n) scan — fine for the deliberate-gesture use case but don't fire it 60 times a second.


Spotter vs. Dwell — when to use which

Both let the user "point at an element." The difference is in the gesture:

Surface Trigger Best for
Dwell Long-press an element Mobile + desktop. User holds on the element itself; no separate picker mode. See Dwell.
Spotter Hover with ring + click Power-user pick flow where the user explicitly enters "point at something" mode (toolbar button, /inspect skill).

Rule of thumb: if the gesture happens in passing (user is reading, sees something, long-presses it), use Dwell. If the gesture is a mode the user enters ("Help me pick an element"), use Spotter.

The two can coexist — Dwell takes long-press, Spotter takes hover+click — but if both are enabled, document why; users get confused when two similar gestures both pin elements with subtly different behaviour.


When NOT to use Spotter

  • Selecting text — just use window.getSelection(). The palette's chip-bar reads it automatically on open.
  • Confirming an agent step — that's gestures (onAccept).
  • Long-press to pin — use Dwell. Spotter's ring is for "rest your mouse and we'll frame what's under it," not for explicit long-press.
  • Hover tooltips — use HTML title or your own tooltip lib. Spotter is heavier and has app-level state.