# Folded note

> An AI agent folded from an index card. It nods, listens, writes on its own lines and crumples.

An index card standing on your page, creased in the middle, with its top half as a head. It nods while idle, stands up to listen, tips back and dog-ears its corner while it thinks, and writes on its own lines while it works.

When it's done it ticks itself off. When something breaks it crumples and slumps. It's drawn in outline, so it sits in a calm interface without shouting.

## Install

Copy these files into your project:

### paper-note.js

```js
// Folded note: an AI agent folded from an index card, by Baked Design Studio (baked.design/interfaces).
// A card standing on your page, creased in the middle. The top half is its head: it nods while idle, stands up
// to listen, tips back and dog-ears its corner while it thinks, writes on its own lines while it works, ticks
// itself when it's done and crumples when something breaks.
//
//   import { mount } from "./paper-note.js";
//   const note = mount(document.querySelector("#agent"));
//   note.set("working");              // idle · listening · thinking · working · done · error
//   note.hear();                      // call on each keystroke while listening
//   note.tune({ patience: 0.2 });     // 0 to 1: patience, confidence, chattiness
//
// Lines take the text colour (currentColor); the paper is --note-paper (your page colour by default).
// 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; };
const W = 62, H = 58, D = Math.PI / 180;
const LINES = [16, 30, 44];

// a joined-up scribble along one ruled line, so "writing" looks like handwriting rather than a bar
function scribble(y, seed) {
  let d = `M-46,${y}`;
  for (let x = -46; x <= 44; x += 3) {
    const w = Math.sin(x * 0.45 + seed) * 3.2 + Math.sin(x * 0.17 + seed * 2) * 1.5;
    d += `L${(x + Math.cos(x * 0.45 + seed) * 1.6).toFixed(1)},${(y - 3 + w).toFixed(1)}`;
  }
  return d;
}

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: "-86 -72 172 144", "aria-hidden": "true" });
  Object.assign(svg.style, { width: "100%", height: "100%", display: "block", overflow: "visible" });
  const line = { fill: "none", stroke: "currentColor", "stroke-width": 1.6, "stroke-linejoin": "round", "stroke-linecap": "round" };
  const paper = { ...line, fill: "var(--note-paper, Canvas)" };
  const ground = mk("path", { ...line, d: `M-84,${H + 2}H84`, "stroke-opacity": 0.25 });
  const back = mk("polygon", paper), ear = mk("polygon", paper), crease = mk("path", { ...line, "stroke-opacity": 0.5 });
  const rules = LINES.map((y) => mk("path", { ...line, d: `M-48,${y}H48`, "stroke-opacity": 0.22, "stroke-width": 1 }));
  const words = LINES.map((y, i) => mk("path", { ...line, d: scribble(y, i * 2.3 + 1), "stroke-width": 1.4 }));
  const tick = mk("path", { ...line, d: "M-16,28 L-4,42 L22,12", "stroke-width": 3.2 });
  const flap = mk("polygon", paper);
  const eyes = [-1, 1].map(() => { const g = mk("g"); const slit = mk("rect", { fill: "currentColor" }); const arc = mk("path", { ...line, "stroke-width": 2.4, d: "M-6,2 Q0,-5 6,2" }); const x = mk("path", { ...line, "stroke-width": 2.2, d: "M-4,-4L4,4M4,-4L-4,4" }); g.append(slit, arc, x); return { g, slit, arc, x }; });
  svg.append(ground, back, ...rules, ...words, tick, ear, crease, flap, ...eyes.map((e) => e.g));
  el.append(svg);
  const lens = words.map((w) => w.getTotalLength?.() || 140), tickLen = tick.getTotalLength?.() || 50;

  let state = STATES.includes(options.state) ? options.state : "idle";
  let t = 0, last = 0, raf = 0, seen = true, heard = 0, since = 0, lastMove = performance.now();
  const cur = { tilt: 14, ear: 0, crumple: 0, ink: 0, tick: 0, open: 1, look: 0 };

  function goal() {
    const s = 1.45 - p.patience * 0.8, amp = 0.6 + p.chattiness * 0.8;
    const bored = (performance.now() - lastMove) / 1000 > 3 + 8 * (1 - p.chattiness);
    switch (state) {
      case "listening": return { tilt: -4 + heard * 10 * Math.sin(t * 30), ear: 0, crumple: 0, ink: 0, tick: 0, open: 1.3, look: 0 };
      case "thinking": return { tilt: -24 + 6 * Math.sin(t * 1.4 * s), ear: 0.5 + 0.5 * Math.sin(t * 2 * s), crumple: 0, ink: 0, tick: 0, open: 0.8, look: -1 };
      case "working": return { tilt: 12 + 9 * Math.abs(Math.sin(t * 3 * s)) * amp, ear: 0, crumple: 0, ink: (t * 0.45 * s) % 3.6, tick: 0, open: 0.7, look: 1 };
      case "done": return { tilt: 6 + 14 * Math.max(0, Math.sin(since * 9)) * Math.max(0, 1 - since * 0.8), ear: 0, crumple: 0, ink: 3, tick: Math.min(1, since * 2), open: 1, look: 0 };
      case "error": return { tilt: 38, ear: 0.2, crumple: 1, ink: 1.4, tick: 0, open: 1, look: 1 };
      default: return { tilt: 14 + 4 * Math.sin(t * 1.2 * s) * amp + (bored ? 10 * Math.max(0, Math.sin(t * 0.9)) : 0), ear: 0, crumple: 0, ink: 0, tick: 0, open: 1, look: 0 };
    }
  }
  const jit = (x, y, k) => cur.crumple ? [x + Math.sin(x * 0.7 + y * 1.3 + k) * 3.5 * cur.crumple, y + Math.cos(x * 1.1 - y * 0.6 + k) * 3 * cur.crumple] : [x, y];
  const pts = (arr) => arr.map(([x, y], i) => jit(x, y, i).map((v) => v.toFixed(2)).join(",")).join(" ");
  function draw() {
    const th = cur.tilt * D, c = Math.cos(th), sn = Math.sin(th);
    const shake = state === "error" ? Math.sin(t * 40) * 3 * Math.max(0, 1 - since * 1.5) : 0;
    svg.style.transform = shake ? `translateX(${shake.toFixed(2)}px)` : "";
    // the bottom half, with its corner folded over by `ear`
    const f = 16 * cur.ear;
    back.setAttribute("points", pts([[-W, 0], [W, 0], [W, H - f], [W - f, H], [-W, H]]));
    ear.setAttribute("points", pts([[W, H - f], [W - f, H - f], [W - f, H]]));
    ear.style.display = f > 0.5 ? "" : "none";
    crease.setAttribute("d", cur.crumple > 0.05 ? `M-${W},22 L-30,30 L-8,18 L14,34 L36,24 L${W},32` : "M0,0");
    crease.style.opacity = cur.crumple;
    // writing: each line fills in turn
    words.forEach((w, i) => { const k = Math.max(0, Math.min(1, cur.ink - i)); w.style.strokeDasharray = lens[i]; w.style.strokeDashoffset = (lens[i] * (1 - k)).toFixed(1); w.style.opacity = k > 0.01 ? 1 - cur.tick * 0.6 : 0; });
    tick.style.strokeDasharray = tickLen; tick.style.strokeDashoffset = (tickLen * (1 - cur.tick)).toFixed(1); tick.style.opacity = cur.tick > 0.01 ? 1 : 0;
    // the top half: hinged at the crease, foreshortened as it tips, a little wider when it leans toward you
    const top = -H * c, half = W * (1 + 0.16 * sn);
    flap.setAttribute("points", pts([[-W, 0], [W, 0], [half, top], [-half, top]]));
    const face = c > 0.25;
    eyes.forEach(({ g, slit, arc, x }, i) => {
      g.style.display = face ? "" : "none"; if (!face) return;
      const ey = top * 0.55 + cur.look * 3 * c, ex = (i ? 1 : -1) * 20 * (1 + 0.16 * sn * 0.55);
      const blink = state !== "error" && state !== "done" && (t * (1.1 - p.patience * 0.6)) % 3.6 < 0.1;
      const h = Math.max(1.2, 11 * c * cur.open * (blink ? 0.15 : 1));
      slit.setAttribute("x", -2.6); slit.setAttribute("width", 5.2); slit.setAttribute("y", (-h / 2).toFixed(2)); slit.setAttribute("height", h.toFixed(2)); slit.setAttribute("rx", 2.6);
      slit.style.display = state === "done" || state === "error" ? "none" : "";
      arc.style.display = state === "done" ? "" : "none"; x.style.display = state === "error" ? "" : "none";
      g.setAttribute("transform", `translate(${ex.toFixed(2)},${ey.toFixed(2)}) scale(1,${Math.max(0.3, c).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 * 2.5);
    const g = goal(), fk = 1 - Math.exp(-dt * (4 + p.confidence * 10));
    for (const k in g) cur[k] += (g[k] - cur[k]) * (k === "ink" && g.ink < cur.ink - 1 ? 1 : fk);
    draw();
    if (seen && !document.hidden && !still.matches) raf = requestAnimationFrame(frame);
  }
  const wake = () => { if (!raf) { last = 0; raf = requestAnimationFrame(frame); } };
  const settle = () => { since = 9; Object.assign(cur, goal()); if (state === "working") cur.ink = 2; draw(); };

  const onMove = () => { lastMove = performance.now(); };
  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 (state === "working") cur.ink = 0; if (still.matches) settle(); else wake(); },
    hear() { heard = Math.min(1, heard + 0.5); lastMove = performance.now(); 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 "./paper-note.js";

const note = mount(document.querySelector("#agent"));
note.set("working");
```

## 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.
- Lines use currentColor. Set --note-paper to your page colour if the paper should match it (it uses the system page colour by default).
- 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/
