Book a call

Notch agent

Tap the notch to open or close it. When it flags something, it waits for your answer.

Built the way Apple's Dynamic Island moves. One black shape grows out of the camera and springs to its new size with a little overshoot, and only then does the content come out of it, from a soft blur. On the way back the content leaves first and the shape follows. A second job buds off as its own bubble, like a drop of liquid.

It shows everything an agent does: it appears, listens, understands (pulling out what you asked for), acts, creates, flags anything it needs your OK for, finishes and hides. Tap the notch to open or close it. On a laptop it sits flush with the top of the screen and drops down as a panel.

Install with AI

Paste this into Claude Code, Cursor or any AI coding tool.

Add the Notch agent component from https://baked.design/interfaces/notch-agent.md to this project. Follow the install steps on that page and use the code exactly as written.

Install by hand

Copy this file into your project.

notch-agent.js
// Notch agent: an AI agent that lives in the notch of a phone or a laptop, by Baked Design Studio
// (baked.design/interfaces/notch-agent). Built the way Apple's Dynamic Island moves: one black shape grows out
// of the camera, springs to its new size with a little overshoot, and only then does the content come out of it,
// from a soft blur. On the way back the content goes first and the shape follows. A second job buds off as its
// own bubble, like a drop of liquid. Everything inside sits concentric with the shape.
//
// The agent appears, listens, understands (it pulls out what you asked for), acts, creates, flags anything it
// needs your OK for, finishes and hides again. Tap the notch to open or close it.
//
//   import { mount } from "./notch-agent.js";
//   const agent = mount(document.querySelector("#agent"), { device: "phone" });
//   agent.show("listening");          // hidden · appear · listening · understanding · acting · creating · flag · done
//   agent.device("laptop");           // "phone" or "laptop"
//   agent.play();                     // the whole story, start to finish
//   agent.on("approve", () => {});    // the flag's buttons: "approve" and "dismiss"
//
// No dependencies. Words are set in your page's font.

const SCENES = ["hidden", "appear", "listening", "understanding", "acting", "creating", "flag", "done"];
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);

// Sizes of the shape, per device: the camera alone, compact (something either side of the camera) and open.
const GEO = {
  phone: { W: 380, H: 260, top: 11, rest: [114, 34], compact: [196, 34], open: [352, 0], r: 42, flush: false },
  laptop: { W: 560, H: 300, top: 0, rest: [172, 30], compact: [292, 30], open: [430, 0], r: 26, flush: true },
};

const STORY = { // what the agent says in the story; pass your own with { words, chips, steps, made, ask } to mount
  words: "Book a table for four on Friday, somewhere quiet",
  chips: ["Table for 4", "Friday, 7 pm", "Quiet"],
  steps: ["Checked three places", "Found a quiet table", "Held it for Friday"],
  made: { title: "Friday dinner", line: "Table for four, 7 pm" },
  ask: { title: "Needs your OK", line: "Share your phone number with the restaurant?" },
  done: "Booked for Friday",
};

const STYLE = `
.na-wrap{ position:relative; width:100%; overflow:hidden; }
.na{ position:absolute; left:0; top:0; transform-origin:0 0; font:inherit; -webkit-font-smoothing:antialiased; }
.na *{ box-sizing:border-box; }
.na-scr{ position:absolute; inset:0; overflow:hidden; background:var(--na-screen, #f3f3f1); color:#111; border:1.5px solid var(--na-frame, currentColor); }
.na-phone .na-scr{ border-radius:44px 44px 0 0; border-bottom:0; }
.na-laptop .na-scr{ border-radius:12px 12px 0 0; border-bottom:0; }
.na-bar{ position:absolute; left:0; right:0; top:0; display:flex; justify-content:space-between; align-items:center; font-size:13px; font-weight:600; letter-spacing:-.01em; }
.na-phone .na-bar{ height:56px; padding:0 30px 0 36px; font-size:15px; }
.na-laptop .na-bar{ height:30px; padding:0 14px; font-weight:500; font-size:12px; background:rgba(255,255,255,.55); }
.na-bar .m{ display:flex; gap:14px; } .na-bar .m b{ font-weight:700; }
.na-bat{ display:inline-block; width:24px; height:11px; border:1.5px solid currentColor; border-radius:3.5px; position:relative; opacity:.9; }
.na-bat::after{ content:""; position:absolute; inset:1.5px 6px 1.5px 1.5px; background:currentColor; border-radius:1.5px; }
.na-rows{ position:absolute; left:28px; right:28px; display:grid; gap:12px; }
.na-phone .na-rows{ top:86px; } .na-laptop .na-rows{ top:72px; left:120px; right:60px; }
.na-rows i{ display:block; height:10px; border-radius:6px; background:rgba(0,0,0,.08); }
.na-rows i:nth-child(1){ width:60%; height:18px; background:rgba(0,0,0,.12); } .na-rows i:nth-child(3){ width:80%; } .na-rows i:nth-child(4){ width:45%; }
.na-side{ position:absolute; left:0; top:30px; bottom:0; width:96px; border-right:1px solid rgba(0,0,0,.06); padding:42px 14px; display:grid; align-content:start; gap:10px; }
.na-side i{ display:block; height:8px; border-radius:4px; background:rgba(0,0,0,.08); }
.na-shape{ position:absolute; inset:-40px 0 0; }
.na-shape.goo{ filter:url(#na-goo); }
.na-isl, .na-bub{ position:absolute; background:#000; }
.na-cnt{ position:absolute; overflow:hidden; color:#fff; cursor:pointer; -webkit-tap-highlight-color:transparent; }
.na-cnt:focus-visible{ outline:2px solid var(--na-frame, currentColor); outline-offset:3px; }
.na-in{ position:absolute; left:50%; top:0; transform:translateX(-50%); }
.na-bubc{ position:absolute; pointer-events:none; display:grid; place-items:center; }
/* content comes out of the shape: from a soft blur, a little small, then settles */
.na-e{ opacity:0; filter:blur(6px); transform:scale(.92); transition:opacity .32s cubic-bezier(.2,.8,.2,1), filter .38s cubic-bezier(.2,.8,.2,1), transform .42s cubic-bezier(.34,1.4,.64,1); }
.na-e.on{ opacity:1; filter:none; transform:none; }
.na-e.off{ opacity:0; filter:blur(4px); transform:scale(.96); transition-duration:.14s; }
/* the agent's mark: a small orb that breathes, listens, spins and settles */
.na-orb{ position:absolute; width:22px; height:22px; border-radius:50%; background:radial-gradient(circle at 35% 30%, #fff, #b9c6ff 45%, #6a7dff 70%, #3a3fd6); box-shadow:0 0 12px rgba(120,140,255,.55); }
.na-orb.think{ animation:naPulse 1.1s ease-in-out infinite; }
.na-orb.busy::after{ content:""; position:absolute; inset:-4px; border-radius:50%; border:2px solid transparent; border-top-color:#fff; animation:naSpin .9s linear infinite; }
@keyframes naPulse{ 50%{ transform:scale(.82); box-shadow:0 0 18px rgba(120,140,255,.9); } }
@keyframes naSpin{ to{ transform:rotate(1turn); } }
.na-wave{ position:absolute; display:flex; gap:3px; align-items:center; height:22px; }
.na-wave i{ display:block; width:3px; border-radius:2px; background:#fff; height:6px; animation:naWave .9s ease-in-out infinite; }
.na-wave i:nth-child(2){ animation-delay:-.2s } .na-wave i:nth-child(3){ animation-delay:-.45s } .na-wave i:nth-child(4){ animation-delay:-.1s } .na-wave i:nth-child(5){ animation-delay:-.6s }
@keyframes naWave{ 50%{ height:20px; } }
.na-t{ position:absolute; font-size:13px; line-height:18px; font-weight:600; white-space:nowrap; }
.na-t.mut{ color:rgba(255,255,255,.55); font-weight:500; }
.na-sh{ background:linear-gradient(90deg, rgba(255,255,255,.45), #fff, rgba(255,255,255,.45)); background-size:200% 100%; -webkit-background-clip:text; background-clip:text; color:transparent; animation:naShine 1.6s linear infinite; }
@keyframes naShine{ to{ background-position:-200% 0; } }
.na-big{ position:absolute; left:28px; right:28px; font-size:15px; line-height:21px; font-weight:600; }
.na-lab{ position:absolute; left:28px; font-size:12px; line-height:16px; color:rgba(255,255,255,.55); font-weight:500; display:flex; gap:8px; align-items:center; }
.na-chips{ position:absolute; left:28px; right:28px; display:flex; flex-wrap:wrap; gap:6px; }
.na-chip{ padding:5px 11px; border-radius:99px; background:rgba(255,255,255,.14); font-size:12.5px; line-height:16px; font-weight:600; }
.na-steps{ position:absolute; left:28px; right:28px; display:grid; gap:7px; font-size:13px; line-height:18px; }
.na-steps div{ display:flex; gap:9px; align-items:center; }
.na-tk{ width:16px; height:16px; border-radius:50%; background:#34c759; display:grid; place-items:center; flex:none; }
.na-tk svg{ width:10px; height:10px; }
.na-card{ position:absolute; left:28px; right:28px; height:74px; border-radius:16px; background:rgba(255,255,255,.1); padding:12px 14px; overflow:hidden; }
.na-card i{ display:block; height:7px; border-radius:4px; background:rgba(255,255,255,.28); margin-top:8px; transform-origin:0 50%; animation:naGrow .7s cubic-bezier(.2,.8,.2,1) both; }
.na-card b{ display:block; font-size:14px; line-height:18px; }
.na-card span{ display:block; font-size:12px; line-height:16px; color:rgba(255,255,255,.6); }
@keyframes naGrow{ from{ transform:scaleX(0); } }
.na-flag{ width:22px; height:22px; border-radius:50%; background:#ff9f0a; color:#000; display:grid; place-items:center; font-weight:800; font-size:14px; }
.na-btns{ position:absolute; left:28px; right:28px; display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.na-btns button{ all:unset; box-sizing:border-box; text-align:center; padding:9px 0; border-radius:99px; font-size:13px; line-height:18px; font-weight:600; cursor:pointer; background:rgba(255,255,255,.16); color:#fff; transition:transform .14s cubic-bezier(.2,.8,.2,1), background-color .18s; }
.na-btns button.yes{ background:#fff; color:#000; }
.na-btns button:active{ transform:scale(.95); } .na-btns button:focus-visible{ outline:2px solid #fff; outline-offset:2px; }
@media (prefers-reduced-motion: reduce){ .na-e{ transition:none; filter:none; transform:none; } .na-orb, .na-orb::after, .na-wave i, .na-sh, .na-card i{ animation:none !important; } }
`;

const TICK = `<svg viewBox="0 0 10 10"><path d="M2 5.2 L4.2 7.3 L8 3" fill="none" stroke="#000" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>`;

export function mount(el, options = {}) {
  if (!document.getElementById("na-style")) {
    const s = document.createElement("style"); s.id = "na-style"; s.textContent = STYLE; document.head.append(s);
    // the goo: blur the black shapes together, then sharpen the edge back, so a bubble buds off like liquid
    const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
    svg.setAttribute("width", "0"); svg.setAttribute("height", "0"); svg.style.position = "absolute";
    svg.innerHTML = `<filter id="na-goo"><feGaussianBlur in="SourceGraphic" stdDeviation="5" result="b"/><feColorMatrix in="b" values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 22 -10" result="g"/><feComposite in="SourceGraphic" in2="g" operator="atop"/></filter>`;
    document.body.append(svg);
  }
  const story = { ...STORY, ...options.story };
  const still = matchMedia("(prefers-reduced-motion: reduce)");
  const wrap = document.createElement("div"); wrap.className = "na-wrap"; el.append(wrap);
  const root = document.createElement("div"); wrap.append(root);
  const listeners = {};
  const emit = (n) => (listeners[n] || []).forEach((f) => f());

  let dev = options.device === "laptop" ? "laptop" : "phone", scene = "hidden", open = false;
  let scr, shape, isl, bub, cnt, inner, bubc, timers = [], raf = 0, last = 0, seq = 0;
  // the shape's size and the bubble's offset, each on a spring (value, velocity)
  const S = { w: [0, 0], h: [0, 0], b: [0, 0], press: [1, 0] };
  const target = { w: 0, h: 0, b: 0, press: 1 };

  function build() {
    const g = GEO[dev];
    root.className = `na na-${dev}`;
    root.style.width = g.W + "px"; root.style.height = g.H + "px";
    root.innerHTML = `<div class="na-scr">
        ${dev === "laptop" ? `<div class="na-bar"><span class="m"><b>Baked</b><span>File</span><span>Edit</span><span>View</span></span><span>Fri 9:41</span></div><div class="na-side"><i></i><i></i><i></i><i></i></div>`
          : `<div class="na-bar"><span>9:41</span><span class="na-bat"></span></div>`}
        <div class="na-rows"><i></i><i></i><i></i><i></i><i></i></div></div>
      <div class="na-shape"><div class="na-isl"></div><div class="na-bub"></div></div>
      <div class="na-cnt" role="button" tabindex="0" aria-label="Agent: tap to open or close"><div class="na-in"></div></div>
      <div class="na-bubc"></div>`;
    [scr, shape, isl, bub, cnt, inner, bubc] = [".na-scr", ".na-shape", ".na-isl", ".na-bub", ".na-cnt", ".na-in", ".na-bubc"].map((c) => root.querySelector(c));
    cnt.addEventListener("click", (e) => { if (e.target.closest("button")) return; toggle(); });
    cnt.addEventListener("keydown", (e) => { if ((e.key === "Enter" || e.key === " ") && !e.target.closest("button")) { e.preventDefault(); toggle(); } });
    cnt.addEventListener("pointerdown", () => { target.press = 0.96; wake(); });
    addEventListener("pointerup", () => { target.press = 1; wake(); });
    if (dev === "laptop") { cnt.addEventListener("pointerenter", () => { if (!open) { target.h += 4; wake(); } }); cnt.addEventListener("pointerleave", () => { size(); wake(); }); }
    S.w[0] = g.rest[0]; S.h[0] = g.rest[1]; fit(); render(); paint(true);
  }
  function fit() { const k = Math.min(1, wrap.clientWidth / GEO[dev].W) || 1; root.style.transform = `scale(${k})`; wrap.style.height = GEO[dev].H * k + "px"; }

  // what each scene shows: its size, and its content (compact: either side of the camera; open: below it)
  function view() {
    const g = GEO[dev], c = g.compact[0], half = c / 2, edge = 12;
    const side = (l, r) => `<span class="na-e" style="position:absolute;left:${-half + edge}px;top:${(g.compact[1] - 22) / 2}px">${l}</span><span class="na-e" style="position:absolute;right:${-half + edge}px;top:${(g.compact[1] - 22) / 2}px;display:flex;align-items:center;height:22px">${r}</span>`;
    const top = g.flush ? 14 : 20, W = g.open[0];
    const box = (h, html) => ({ open: true, h, html: `<div style="position:relative;width:${W}px;height:${h}px;left:${-W / 2}px">${html}</div>` });
    const orb = (cls = "") => `<span class="na-orb ${cls}" style="position:relative;display:block"></span>`;
    switch (scene) {
      case "appear": return { size: "compact", html: side(orb(), `<span class="na-t mut" style="position:relative">Hi</span>`) };
      case "listening": return open ? box(118, `<div class="na-lab na-e" style="top:${top}px">${orb()}<span>Listening</span></div><div class="na-big na-e" style="top:${top + 34}px">“${esc(story.words)}”</div>`)
        : { size: "compact", html: side(orb(), `<span class="na-wave" style="position:relative"><i></i><i></i><i></i><i></i><i></i></span>`) };
      case "understanding": return box(150, `<div class="na-lab na-e" style="top:${top}px">${orb("think")}<span class="na-sh">Understanding</span></div>
        <div class="na-big na-e" style="top:${top + 32}px;color:rgba(255,255,255,.6);font-weight:500">“${esc(story.words)}”</div>
        <div class="na-chips" style="top:${top + 84}px">${story.chips.map((c, i) => `<span class="na-chip na-e" data-d="${200 + i * 140}">${esc(c)}</span>`).join("")}</div>`);
      case "acting": return open ? box(36 + story.steps.length * 25 + 28, `<div class="na-lab na-e" style="top:${top}px">${orb("busy")}<span>Working on it</span></div>
          <div class="na-steps" style="top:${top + 32}px">${story.steps.map((s, i) => `<div class="na-e" data-d="${150 + i * 260}"><span class="na-tk">${TICK}</span>${esc(s)}</div>`).join("")}</div>`)
        : { size: "compact", html: side(orb("busy"), `<span class="na-t na-sh" style="position:relative">Booking</span>`), bubble: true };
      case "creating": return box(146, `<div class="na-lab na-e" style="top:${top}px">${orb("think")}<span class="na-sh">Making your invite</span></div>
        <div class="na-card na-e" data-d="120" style="top:${top + 32}px"><b class="na-e" data-d="760">${esc(story.made.title)}</b><span class="na-e" data-d="900">${esc(story.made.line)}</span><i style="width:70%;animation-delay:.25s"></i><i style="width:45%;animation-delay:.4s"></i></div>`);
      case "flag": return box(148, `<div class="na-lab na-e" style="top:${top}px"><span class="na-flag">!</span><span style="color:#fff;font-weight:600;font-size:13px">${esc(story.ask.title)}</span></div>
        <div class="na-big na-e" style="top:${top + 32}px">${esc(story.ask.line)}</div>
        <div class="na-btns na-e" data-d="160" style="top:${top + 82}px"><button type="button" data-a="dismiss">Not now</button><button type="button" class="yes" data-a="approve">Allow</button></div>`);
      case "done": return { size: "compact", html: side(`<span class="na-tk" style="width:22px;height:22px">${TICK}</span>`, `<span class="na-t" style="position:relative">${esc(story.done)}</span>`) };
      default: return { size: "rest", html: "" };
    }
  }
  function size(v = view()) {
    const g = GEO[dev];
    if (v.open) { target.w = g.open[0]; target.h = v.h; }
    else if (v.size === "compact") { target.w = g.compact[0]; target.h = g.compact[1]; }
    else { target.w = g.rest[0]; target.h = g.rest[1]; }
    target.b = v.bubble ? 1 : 0;
  }

  // swap the content: the old content leaves quickly, the shape springs to its new size, then the new content
  // comes out of it (a beat later, so the shape always leads)
  function paint(first = false) {
    const my = ++seq, v = view();
    const old = [...inner.querySelectorAll(".na-e")];
    old.forEach((e) => { e.classList.remove("on"); e.classList.add("off"); });
    const go = () => {
      if (my !== seq) return;
      size(v); wake();
      inner.innerHTML = v.html;
      inner.querySelectorAll("[data-a]").forEach((b) => b.addEventListener("click", () => emit(b.dataset.a)));
      cnt.setAttribute("aria-label", `Agent: ${scene === "hidden" ? "hidden" : scene}${v.open ? ", open" : ""}`);
      const items = [...inner.querySelectorAll(".na-e")];
      if (still.matches) { items.forEach((e) => e.classList.add("on")); return; }
      const lead = v.open ? 170 : 120;
      items.forEach((e, i) => setTimeout(() => my === seq && e.classList.add("on"), lead + Number(e.dataset.d ?? i * 40)));
    };
    if (first || still.matches || !old.length) go(); else setTimeout(go, 130);
  }

  function render() {
    const g = GEO[dev], w = S.w[0] * S.press[0], h = Math.max(g.rest[1] * 0.6, S.h[0]) * (S.press[0] < 1 ? 0.99 : 1);
    const x = (g.W - w) / 2, top = g.flush ? 0 : g.top;
    const r = g.flush ? `0 0 ${Math.min(g.r, h / 2)}px ${Math.min(g.r, h / 2)}px` : `${Math.min(g.r, h / 2)}px`;
    // the laptop's notch reaches up past the screen edge, so it stays flush (and the goo can't round its top)
    Object.assign(isl.style, { left: x + "px", top: top + 40 - (g.flush ? 20 : 0) + "px", width: w + "px", height: h + (g.flush ? 20 : 0) + "px", borderRadius: r });
    Object.assign(cnt.style, { left: x + "px", top: top + "px", width: w + "px", height: h + "px", borderRadius: r });
    inner.style.width = "0px";
    // the bubble buds off to the right of the shape
    const bs = g.compact[1], bx = x + w - bs + S.b[0] * (bs + 10);
    Object.assign(bub.style, { left: bx + "px", top: top + 40 + (g.flush ? 0 : 0) + "px", width: bs + "px", height: bs + "px", borderRadius: "50%", opacity: S.b[0] > 0.02 ? 1 : 0 });
    shape.classList.toggle("goo", S.b[0] > 0.02 && S.b[0] < 0.98);
    // like iOS, the status icons on the right step aside while a bubble is out
    const bat = root.querySelector(".na-bat"); if (bat) bat.style.opacity = String(Math.max(0, 1 - S.b[0] * 2));
    Object.assign(bubc.style, { left: bx + "px", top: top + "px", width: bs + "px", height: bs + "px", opacity: Math.max(0, S.b[0] * 1.4 - 0.4) });
  }
  function frame(now) {
    raf = 0;
    const dt = Math.min(0.033, (now - (last || now)) / 1000); last = now;
    // springs with a little overshoot, like the island's own bounce (stiff for size, softer for the bubble)
    let moving = false;
    for (const [k, w, z] of [["w", 15, 0.72], ["h", 15, 0.72], ["b", 9, 0.6], ["press", 30, 0.7]]) {
      const s = S[k], a = w * w * (target[k] - s[0]) - 2 * z * w * s[1];
      s[1] += a * dt; s[0] += s[1] * dt;
      if (Math.abs(target[k] - s[0]) > 0.05 || Math.abs(s[1]) > 0.05) moving = true; else { s[0] = target[k]; s[1] = 0; }
    }
    render();
    if (moving && !document.hidden) raf = requestAnimationFrame(frame);
  }
  const wake = () => {
    if (still.matches) { for (const k in S) { S[k][0] = target[k]; S[k][1] = 0; } render(); return; }
    if (!raf) { last = 0; raf = requestAnimationFrame(frame); }
  };

  // the bubble's own content: a ring that fills while the job runs
  function bubbleRing() {
    bubc.innerHTML = `<svg width="22" height="22" viewBox="0 0 22 22"><circle cx="11" cy="11" r="8" fill="none" stroke="rgba(255,255,255,.25)" stroke-width="2.5"/><circle cx="11" cy="11" r="8" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-dasharray="50.3" stroke-dashoffset="50.3" transform="rotate(-90 11 11)"><animate attributeName="stroke-dashoffset" from="50.3" to="0" dur="2.4s" fill="freeze"/></circle></svg>`;
  }

  function toggle() {
    if (scene === "hidden") return show("appear");
    const can = { listening: true, acting: true };
    if (can[scene]) { open = !open; paint(); }
    else if (["understanding", "creating", "flag"].includes(scene)) { open = false; show(scene === "flag" ? "flag" : "acting"); }
  }
  function show(next, opts = {}) {
    if (!SCENES.includes(next)) return;
    scene = next; open = !!opts.open;
    if (next === "acting") bubbleRing();
    paint();
  }
  const clear = () => { timers.forEach(clearTimeout); timers = []; };
  function play() {
    clear();
    const steps = [["appear", 1100], ["listening", 1700], ["listening", 2000, true], ["understanding", 2900], ["acting", 2600], ["flag", 0]];
    let at = 0;
    steps.forEach(([s, ms, o]) => { timers.push(setTimeout(() => show(s, { open: o }), at)); at += ms; });
  }
  // the flag waits for you; either answer carries the story on
  const after = (first) => { clear(); let at = 0; [[first, 1500], ["creating", 3000], ["done", 2400], ["hidden", 0]].forEach(([s, ms]) => { timers.push(setTimeout(() => show(s), at)); at += ms; }); };
  listeners.approve = [() => storyOn && after("acting")];
  listeners.dismiss = [() => storyOn && after("acting")];
  let storyOn = false;

  const ro = new ResizeObserver(() => { fit(); }); ro.observe(wrap);
  build();
  return {
    get scene() { return scene; },
    get device() { return dev; },
    show(next, opts) { storyOn = false; clear(); show(next, opts); },
    device(next) { if (!GEO[next] || next === dev) return; dev = next; build(); size(); render(); paint(true); },
    play() { storyOn = true; play(); },
    stop() { storyOn = false; clear(); },
    on(name, fn) { (listeners[name] ||= []).push(fn); },
    destroy() { clear(); cancelAnimationFrame(raf); ro.disconnect(); wrap.remove(); },
  };
}

Use

Example
import { mount } from "./notch-agent.js";

const agent = mount(document.querySelector("#agent"), { device: "laptop" });
agent.show("listening");
agent.show("flag");
agent.on("approve", () => agent.show("acting"));

Notes

All interfaces