# Two dots

> An AI agent that's only a pair of eyes. Its personality is in how they move.

No body and no mascot, just two dots. They follow your cursor, read line by line while the agent works, look up and away while it thinks, smile when it's done and go flat when something breaks.

Patience, confidence and chattiness change everything about how it behaves. A patient agent blinks slowly and settles, a confident one snaps to where it looks, an unsure one glances away, and a chatty one leans in while you type and looks around when it's bored.

## Install

Copy these files into your project:

### two-dots.js

```js
// Two dots: an AI agent that is only a pair of eyes, by Baked Design Studio (baked.design/interfaces).
// No body, no mascot. Its personality is in how it moves: a patient agent blinks slowly and settles, an eager
// one leans toward your cursor while you type, a blunt one snaps between states, and one left alone gets bored
// and looks around. It reads while it works, smiles when it's done and goes flat when something breaks.
//
//   import { mount } from "./two-dots.js";
//   const eyes = mount(document.querySelector("#agent"), { patience: 0.3, confidence: 0.9 });
//   eyes.set("working");              // idle · listening · thinking · working · done · error
//   eyes.hear();                      // call on each keystroke while listening
//   eyes.tune({ chattiness: 1 });     // 0 to 1: patience, confidence, chattiness
//
// The eyes take the element's text colour (currentColor). No dependencies.

const STATES = ["idle", "listening", "thinking", "working", "done", "error"];
const NSV = "http://www.w3.org/2000/svg";
const mk = (tag, attrs) => { const e = document.createElementNS(NSV, tag); for (const k in attrs) e.setAttribute(k, attrs[k]); return e; };

// What each state wants from the eyes: size, where they look, and how open they are.
const POSE = {
  idle: { w: 20, h: 32, gaze: null },
  listening: { w: 22, h: 38, gaze: null },
  thinking: { w: 20, h: 26, gaze: [-0.55, -0.7] },
  working: { w: 24, h: 15, gaze: [0, 0.25] },
  done: { w: 22, h: 30, gaze: [0, -0.1] },
  error: { w: 24, h: 4, gaze: [0, 0.35] },
};

export function mount(el, options = {}) {
  const p = { patience: 0.5, confidence: 0.5, chattiness: 0.5, ...options };
  const still = matchMedia("(prefers-reduced-motion: reduce)");
  const svg = mk("svg", { viewBox: "-100 -60 200 120", "aria-hidden": "true" });
  Object.assign(svg.style, { width: "100%", height: "100%", display: "block", overflow: "visible" });
  const eyes = [-1, 1].map((side) => {
    const g = mk("g", {});
    const pill = mk("rect", { fill: "currentColor" });
    const arc = mk("path", { fill: "none", stroke: "currentColor", "stroke-width": 5, "stroke-linecap": "round", d: "M-11,5 Q0,-9 11,5" });
    g.append(pill, arc); svg.append(g);
    return { side, g, pill, arc };
  });
  el.append(svg);

  let state = STATES.includes(options.state) ? options.state : "idle";
  let t = 0, last = 0, raf = 0, seen = true, heard = 0, since = 0;
  let aim = [0, 0], lastMove = performance.now(), nextBlink = 2, blink = 0, wander = [0, 0], nextWander = 3;
  const cur = { w: 20, h: 32, gx: 0, gy: 0, smile: 0, lean: 0 };

  function target(now) {
    const pose = POSE[state];
    let gaze = pose.gaze ?? aim;
    const idleFor = (now - lastMove) / 1000;
    // left alone, it gets bored and looks around (sooner if it's chatty)
    if ((state === "idle" || state === "listening") && idleFor > 2.5 + 8 * (1 - p.chattiness)) gaze = wander;
    if (state === "thinking") gaze = [gaze[0] + 0.18 * Math.cos(t * 0.9), gaze[1] + 0.12 * Math.sin(t * 1.3)];
    if (state === "working") { const line = Math.floor(t / 1.6) % 3, k = (t % 1.6) / 1.6; gaze = [-0.7 + 1.4 * Math.min(1, k * 1.25), -0.25 + line * 0.3]; }
    if (state === "done") gaze = [0, -0.1 + 0.05 * Math.sin(t * 3)];
    // an unsure agent glances away from you now and then
    if (pose.gaze === null && p.confidence < 0.4 && Math.sin(t * 0.8) > 0.92) gaze = [gaze[0] * -0.6, 0.5];
    const size = 0.85 + p.confidence * 0.25;
    return { w: pose.w * size, h: pose.h * size * (1 + 0.15 * heard), gx: gaze[0], gy: gaze[1], smile: state === "done" ? 1 : 0,
      lean: state === "listening" ? aim[0] * (0.4 + p.chattiness * 0.6) : 0 };
  }
  function draw() {
    const shakeX = state === "error" ? Math.sin(t * 38) * 6 * Math.max(0, 1 - since * 1.6) : 0;
    const open = 1 - blink;
    eyes.forEach(({ side, g, pill, arc }) => {
      const x = side * 30 + cur.gx * 22 + cur.lean * 10 + shakeX, y = cur.gy * 14 - cur.lean * cur.lean * 4;
      const h = Math.max(3, cur.h * open * (1 - cur.smile)), w = cur.w;
      pill.setAttribute("x", (-w / 2).toFixed(2)); pill.setAttribute("y", (-h / 2).toFixed(2));
      pill.setAttribute("width", w.toFixed(2)); pill.setAttribute("height", h.toFixed(2)); pill.setAttribute("rx", Math.min(w, h) / 2);
      pill.style.opacity = 1 - cur.smile; arc.style.opacity = cur.smile;
      g.setAttribute("transform", `translate(${x.toFixed(2)},${y.toFixed(2)}) rotate(${(state === "error" ? side * -6 : cur.lean * side * 4).toFixed(2)})`);
    });
  }
  function frame(now) {
    raf = 0;
    const dt = Math.min(0.05, (now - (last || now)) / 1000); last = now;
    t += dt; since += dt; heard = Math.max(0, heard - dt * 3);
    // blinking: patient agents blink slowly and rarely
    nextBlink -= dt;
    if (nextBlink <= 0 && state !== "error" && state !== "done") { blink = 1; nextBlink = 1.4 + p.patience * 4 + Math.random() * 2; }
    blink = Math.max(0, blink - dt * (6 + (1 - p.patience) * 6));
    nextWander -= dt;
    if (nextWander <= 0) { wander = [Math.random() * 1.6 - 0.8, Math.random() * 1 - 0.5]; nextWander = 0.8 + (1 - p.chattiness) * 2.5 + Math.random(); }
    // confident agents snap to where they look; patient ones drift there
    const goal = target(now), f = 1 - Math.exp(-dt * (4 + p.confidence * 16 - p.patience * 2));
    for (const k in goal) cur[k] += (goal[k] - cur[k]) * (k === "smile" ? 1 - Math.exp(-dt * 10) : f);
    draw();
    if (seen && !document.hidden && !still.matches) raf = requestAnimationFrame(frame);
  }
  const wake = () => { if (!raf) { last = 0; raf = requestAnimationFrame(frame); } };
  const settle = () => { Object.assign(cur, target(performance.now())); blink = 0; draw(); };

  const onMove = (e) => {
    const r = el.getBoundingClientRect(); if (!r.width) return;
    aim = [Math.max(-1, Math.min(1, (e.clientX - (r.left + r.width / 2)) / (r.width * 1.2))), Math.max(-1, Math.min(1, (e.clientY - (r.top + r.height / 2)) / (r.height * 1.5)))];
    lastMove = performance.now();
    if (still.matches) settle();
  };
  addEventListener("pointermove", onMove, { passive: true });
  const io = new IntersectionObserver(([e]) => { seen = e.isIntersecting; if (seen) wake(); });
  io.observe(el);
  const onVis = () => !document.hidden && wake();
  document.addEventListener("visibilitychange", onVis);

  settle(); wake();
  return {
    get state() { return state; },
    set(next) { if (!STATES.includes(next) || next === state) return; state = next; since = 0; if (still.matches) settle(); else wake(); },
    hear() { heard = Math.min(1, heard + 0.6); lastMove = performance.now(); if (p.chattiness > 0.6) blink = 0.6; wake(); },
    tune(next) { Object.assign(p, next); if (still.matches) settle(); else wake(); },
    destroy() { cancelAnimationFrame(raf); io.disconnect(); removeEventListener("pointermove", onMove); document.removeEventListener("visibilitychange", onVis); svg.remove(); },
  };
}
```

## Use

```js
import { mount } from "./two-dots.js";

const eyes = mount(document.querySelector("#agent"), { patience: 0.3, confidence: 0.9 });
input.addEventListener("input", () => { eyes.set("listening"); eyes.hear(); });
```

## Notes

- States: idle, listening, thinking, working, done and error. Call set() whenever your agent changes what it's doing; it moves between states on its own.
- Call hear() on each keystroke while someone types to your agent, so it reacts as they type.
- Small enough to sit in a chat header or next to a send button. The eyes use currentColor.
- tune({ patience, confidence, chattiness }) takes values from 0 to 1, any time.
- Respects reduced motion: with it on, the character holds a still pose for each state.
- It pauses when it's off screen or the tab is hidden. destroy() removes it.
- No dependencies. One plain JavaScript file.

By Baked Design Studio: https://baked.design/interfaces/
