{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "agent-indicator",
  "type": "registry:ui",
  "title": "Agent indicator",
  "description": "What an agent is doing, in hardware: a seven-segment chase for thinking, a tuner for searching, stereo meters for reading, a dot matrix for reasoning, step lights for running and tape reels for writing, each moving only while tokens arrive. A Device part.",
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/agent-indicator.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/agent-indicator 2026.11.08. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Agent indicator: what an agent is doing, said in hardware. Six mechanisms in one 5.6em by 2.6em box, one per phase,\n * so each can stand in for any other: thinking is two seven-segment figures chasing a lit segment round their rim,\n * searching is a tuner whose needle seeks five stations and lights a lamp where it locks, reading is stereo meters\n * that rise with each burst of tokens, reasoning is a dot matrix of drifting waves, running is eight step lights with\n * a playhead, and writing is two tape reels turning at the speed their packs give them. A Device part (worlds.md,\n * Depth): it needs data-material=\"device\" on <html> to look right, and in any other material it still renders\n * without throwing, as a dark cut with ink on it.\n *\n * - Window (material.md, 8): the LCD glass. `--litt-field` is the floor, `--litt-field-ink` is every lit segment, bar and dot,\n *   and the edge is `--litt-field-line` with `--litt-field-shade` on the top wall, as an Input's. The one accent is the\n *   record lamp: the tuner's needle and lamp, the chase's playhead and the reels' lamp. Unlit parts are the ink at\n *   8 to 15%, made with color-mix and an opacity, never a second color.\n * - Moves only while tokens arrive (R4): it runs while a phase is set and `paused` is false, which an app ties to its\n *   stream. Paused, it holds the frame it is on. Off screen, an IntersectionObserver stops it. Under reduced motion\n *   it never runs and holds the frame it rolled to.\n * - One loop: every indicator on the page shares a single requestAnimationFrame, which stops when none is live. A frame\n *   writes attributes and inline styles on the elements it already has and only when a value changed. React renders\n *   the structure once and never re-renders for animation. dt is clamped to 0.1s.\n * - Pre-roll: on mount each mechanism is stepped 1.2s ahead in 1/60s steps before it first paints, so one is never\n *   seen at rest, and a paused one holds a frame that looks mid-thought.\n * - Phase change: the new mechanism mounts already rolled and fades in on the exit-responsive duration (150ms) while\n *   the old one fades out in the same grid cell, then unmounts. Opacity is the only thing that transitions.\n * - Semantics: role=\"status\" with the phase in sr-only words (\"Thinking\"), because the picture is never the only\n *   word (R5). The drawing is aria-hidden. `label` replaces the words.\n */\nimport { useEffect, useLayoutEffect, useRef, useState, type ComponentType } from \"react\";\nimport { useMotionDial } from \"../../lib/litt/motion\";\n\n/* ----------------------------------------------------------------- the shared loop */\n\nconst clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));\n\n/** One frame of a mechanism, `dt` in seconds. */\ntype Sim = { step: (dt: number) => void };\ntype Make = (root: HTMLElement) => Sim;\n\nconst runners = new Set<(dt: number) => void>();\nlet frame = 0;\nlet last = 0;\n\nfunction tick(now: number) {\n  const dt = Math.min(0.1, (now - last) / 1000);\n  last = now;\n  runners.forEach((run) => run(dt));\n  frame = runners.size ? requestAnimationFrame(tick) : 0;\n}\n\n/** Joins the page's single loop and starts it if it is stopped. Returns the way out, which stops it when the last one leaves. */\nfunction join(run: (dt: number) => void) {\n  runners.add(run);\n  if (!frame) {\n    last = performance.now();\n    frame = requestAnimationFrame(tick);\n  }\n  return () => {\n    runners.delete(run);\n    if (!runners.size && frame) {\n      cancelAnimationFrame(frame);\n      frame = 0;\n    }\n  };\n}\n\n/** A setter that touches the DOM only when the value changed. */\nconst attr = (el: Element, name: string) => {\n  let prev = \"\";\n  return (v: string) => {\n    if (v !== prev) {\n      prev = v;\n      el.setAttribute(name, v);\n    }\n  };\n};\nconst css = (el: HTMLElement, prop: \"clipPath\" | \"left\") => {\n  let prev = \"\";\n  return (v: string) => {\n    if (v !== prev) {\n      prev = v;\n      el.style[prop] = v;\n    }\n  };\n};\nconst q = (root: ParentNode, selector: string) => root.querySelector(selector)!;\nconst qa = (root: ParentNode, selector: string) => Array.from(root.querySelectorAll(selector));\n\n/** A seeded generator, so a mechanism plays the same bursts on every load. */\nfunction rng(seed: number) {\n  let a = seed;\n  return () => {\n    a = (a + 0x6d2b79f5) | 0;\n    let t = Math.imul(a ^ (a >>> 15), 1 | a);\n    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\nconst PREROLL = 1.2;\nconst STEP = 1 / 60;\n\n/** Builds a mechanism on the element it renders, rolls it ahead before the first paint, and runs it while `live`. */\nfunction useSim(make: Make, live: boolean) {\n  const ref = useRef<HTMLDivElement>(null);\n  const sim = useRef<Sim | null>(null);\n  useLayoutEffect(() => {\n    const s = make(ref.current!);\n    for (let t = 0; t < PREROLL; t += STEP) s.step(STEP);\n    sim.current = s;\n    return () => {\n      sim.current = null;\n    };\n  }, [make]);\n  useEffect(() => (live ? join((dt) => sim.current?.step(dt)) : undefined), [live]);\n  return ref;\n}\n\n/* ----------------------------------------------------------------- Segments (thinking) */\n\nconst THICK = 6.6;\nconst HALF = THICK / 2;\nconst TIP = 0.71;\nconst LEFT = 5.5;\nconst RIGHT = 31;\nconst TOP = 5;\nconst MID = 33;\nconst BOTTOM = 61;\n\nconst across = (x1: number, x2: number, y: number) =>\n  [[x1 + TIP, y], [x1 + TIP + HALF, y - HALF], [x2 - TIP - HALF, y - HALF], [x2 - TIP, y], [x2 - TIP - HALF, y + HALF], [x1 + TIP + HALF, y + HALF]] as const;\nconst down = (x: number, y1: number, y2: number) =>\n  [[x, y1 + TIP], [x + HALF, y1 + TIP + HALF], [x + HALF, y2 - TIP - HALF], [x, y2 - TIP], [x - HALF, y2 - TIP - HALF], [x - HALF, y1 + TIP + HALF]] as const;\nconst points = (p: readonly (readonly [number, number])[]) => p.map(([x, y]) => `${x},${y}`).join(\" \");\n\n/* a to g: top, top right, bottom right, bottom, bottom left, top left, middle. */\nconst SEGMENTS = [\n  points(across(LEFT, RIGHT, TOP)),\n  points(down(RIGHT, TOP, MID)),\n  points(down(RIGHT, MID, BOTTOM)),\n  points(across(LEFT, RIGHT, BOTTOM)),\n  points(down(LEFT, MID, BOTTOM)),\n  points(down(LEFT, TOP, MID)),\n  points(across(LEFT, RIGHT, MID)),\n];\n\n/* The chase, clockwise from the top left of the left figure: (figure, segment). */\nconst CHASE: readonly (readonly [number, number])[] = [[0, 0], [1, 0], [1, 1], [1, 2], [1, 3], [0, 3], [0, 4], [0, 5]];\n\nfunction makeSegments(root: HTMLElement): Sim {\n  const lit = qa(root, \"polygon\");\n  const set = lit.map((el) => attr(el, \"opacity\"));\n  const level = lit.map(() => 0);\n  let t = 0;\n  let head = -1;\n  return {\n    step(dt) {\n      t += dt;\n      const fade = Math.exp(-dt / 0.11);\n      for (let i = 0; i < level.length; i++) level[i] = level[i]! * fade;\n      const at = Math.floor(t / 0.085);\n      while (head < at) {\n        head++;\n        const [fig, seg] = CHASE[head % CHASE.length]!;\n        level[fig * 7 + seg] = 1;\n      }\n      for (let i = 0; i < level.length; i++) {\n        const b = level[i]! < 0.02 ? 0 : level[i]!;\n        set[i]!((0.1 + 0.9 * b).toFixed(3));\n      }\n    },\n  };\n}\n\nfunction Segments({ live }: { live: boolean }) {\n  const ref = useSim(makeSegments, live);\n  return (\n    <div ref={ref} className={LCD + \" gap-[0.2em]\"}>\n      {[0, 1].map((fig) => (\n        <svg key={fig} viewBox=\"0 0 43 66\" fill=\"currentColor\" className=\"h-[64%] w-auto\">\n          <g transform=\"matrix(1 0 -0.09 1 5.94 0)\">\n            {SEGMENTS.map((p, i) => (\n              <polygon key={i} points={p} opacity=\"0.1\" />\n            ))}\n          </g>\n        </svg>\n      ))}\n    </div>\n  );\n}\n\n/* ----------------------------------------------------------------- Tuner (searching) */\n\nconst STATIONS = [0.12, 0.31, 0.5, 0.69, 0.88];\nconst TICKS = Array.from({ length: 21 }, (_, i) => i);\nconst dialX = (x: number) => 6 + 44 * x;\n\nfunction makeTuner(root: HTMLElement): Sim {\n  const setNeedle = attr(q(root, \"[data-needle]\"), \"transform\");\n  const setLamp = attr(q(root, \"[data-lamp]\"), \"opacity\");\n  let visit = 0;\n  let dir = 1;\n  let goal = STATIONS[0]!;\n  let seek = STATIONS[0]!;\n  let dwell = 0.7;\n  let x = STATIONS[0]!;\n  let v = 0;\n  let lock = 0;\n  let spare = 0;\n  return {\n    step(dt) {\n      if (dwell > 0) {\n        dwell -= dt;\n        if (dwell <= 0) {\n          if (visit + dir < 0 || visit + dir >= STATIONS.length) dir = -dir;\n          visit += dir;\n          goal = STATIONS[visit]!;\n        }\n      } else {\n        const move = 0.5 * dt;\n        if (Math.abs(goal - seek) <= move) {\n          seek = goal;\n          dwell = 0.7;\n        } else seek += Math.sign(goal - seek) * move;\n      }\n      spare += dt;\n      const h = 1 / 240;\n      while (spare >= h) {\n        v += (110 * (seek - x) - 14 * v) * h;\n        x += v * h;\n        spare -= h;\n      }\n      const near = dwell > 0 ? Math.max(0, 1 - Math.abs(x - goal) / 0.02) : 0;\n      lock += (near - lock) * (1 - Math.exp(-dt / 0.06));\n      setNeedle(`translate(${dialX(x).toFixed(2)} 0)`);\n      setLamp((0.12 + 0.88 * lock).toFixed(3));\n    },\n  };\n}\n\nfunction Tuner({ live }: { live: boolean }) {\n  const ref = useSim(makeTuner, live);\n  return (\n    <div ref={ref} className={LCD}>\n      <svg viewBox=\"0 0 56 26\" fill=\"none\" className=\"h-full w-full\">\n        {TICKS.map((i) => (\n          <line key={i} x1={6 + 2.2 * i} x2={6 + 2.2 * i} y1={i % 5 === 0 ? 17.5 : 19.5} y2=\"22\" stroke=\"currentColor\" strokeWidth=\"0.5\" opacity=\"0.35\" />\n        ))}\n        <line x1=\"6\" x2=\"50\" y1=\"22\" y2=\"22\" stroke=\"currentColor\" strokeWidth=\"0.4\" opacity=\"0.2\" />\n        <circle data-lamp cx=\"50.5\" cy=\"5.5\" r=\"1.4\" fill=\"currentColor\" opacity=\"0.12\" style={{ filter: \"drop-shadow(0 0 1.2px currentColor)\" }} />\n        <line data-needle x1=\"0\" x2=\"0\" y1=\"3.5\" y2=\"23.5\" strokeWidth=\"0.9\" strokeLinecap=\"round\" className=\"stroke-litt-accent\" transform={`translate(${dialX(STATIONS[0]!)} 0)`} />\n      </svg>\n    </div>\n  );\n}\n\n/* ----------------------------------------------------------------- Meters (reading) */\n\n/* dB to a fraction of the bar, piecewise linear. */\nconst DB: readonly (readonly [number, number])[] = [[-60, 0], [-40, 0.2], [-20, 0.4], [-12, 0.6], [-6, 0.8], [0, 1]];\nfunction fraction(db: number) {\n  if (db <= DB[0]![0]) return 0;\n  for (let i = 1; i < DB.length; i++) {\n    const [d1, f1] = DB[i]!;\n    const [d0, f0] = DB[i - 1]!;\n    if (db <= d1) return f0 + ((db - d0) / (d1 - d0)) * (f1 - f0);\n  }\n  return 1;\n}\n\nfunction makeMeters(root: HTMLElement): Sim {\n  const rnd = rng(11);\n  const rows = [0, 1].map((i) => {\n    const fill = q(root, `[data-fill=\"${i}\"]`) as HTMLElement;\n    const peak = q(root, `[data-peak=\"${i}\"]`) as HTMLElement;\n    return { clip: css(fill, \"clipPath\"), pos: css(peak, \"left\"), level: -60, peak: -60, hold: 0 };\n  });\n  let burst = 0.8;\n  let gap = 0;\n  let chunk = 0;\n  const raise = (row: (typeof rows)[number], db: number) => {\n    if (db > row.level) {\n      row.level = db;\n      row.peak = Math.max(row.peak, db);\n      row.hold = 0.9;\n    }\n  };\n  return {\n    step(dt) {\n      if (gap > 0) {\n        gap -= dt;\n        if (gap <= 0) burst = 0.5 + rnd() * 1.1;\n      } else {\n        burst -= dt;\n        chunk -= dt;\n        if (chunk <= 0) {\n          chunk = 0.04 + rnd() * 0.07;\n          const db = -17 + rnd() * 13;\n          raise(rows[0]!, db);\n          raise(rows[1]!, db - rnd() * 3);\n        }\n        if (burst <= 0) gap = 0.15 + rnd() * 0.5;\n      }\n      for (const row of rows) {\n        row.level = Math.max(-60, row.level - 26 * dt);\n        row.hold -= dt;\n        if (row.hold <= 0) row.peak = Math.max(row.level, row.peak - 12 * dt);\n        const f = fraction(row.level);\n        row.clip(`inset(0 ${((1 - f) * 100).toFixed(1)}% 0 0)`);\n        const p = fraction(row.peak);\n        row.pos(`calc(${(p * 100).toFixed(1)}% - ${(p * 2).toFixed(2)}px)`);\n      }\n    },\n  };\n}\n\nfunction Meters({ live }: { live: boolean }) {\n  const ref = useSim(makeMeters, live);\n  return (\n    <div ref={ref} className={LCD + \" flex-col gap-[0.45em] px-[0.6em]\"}>\n      {[0, 1].map((i) => (\n        <div key={i} className=\"relative h-[0.48em] w-full shadow-[inset_0_0_0_1px_color-mix(in_oklab,currentColor_15%,transparent)]\">\n          <div data-fill={i} className=\"absolute inset-px bg-current\" style={{ clipPath: \"inset(0 100% 0 0)\" }} />\n          <div data-peak={i} className=\"absolute -top-[12%] h-[124%] w-[2px] bg-current\" />\n        </div>\n      ))}\n    </div>\n  );\n}\n\n/* ----------------------------------------------------------------- Matrix (reasoning) */\n\nconst COLS = 11;\nconst ROWS = 5;\nconst DOTS = Array.from({ length: COLS * ROWS }, (_, i) => i);\n\nfunction makeMatrix(root: HTMLElement): Sim {\n  const dots = qa(root, \"circle\");\n  const set = dots.map((el) => attr(el, \"opacity\"));\n  const level = dots.map(() => 0);\n  let t = 0;\n  return {\n    step(dt) {\n      t += dt;\n      const cx = 5 + 3.2 * Math.sin(0.45 * t);\n      const cy = 2 + 1.4 * Math.cos(0.6 * t);\n      const ease = 1 - Math.exp(-dt / 0.08);\n      for (let i = 0; i < dots.length; i++) {\n        const x = i % COLS;\n        const y = Math.floor(i / COLS);\n        const d = Math.hypot(x - cx, 1.3 * (y - cy));\n        const value = Math.sin(0.55 * x + 1.3 * t) + Math.sin(0.9 * y - 0.9 * t) + Math.sin(0.35 * x + 0.6 * y + 0.7 * t) + Math.sin(1.1 * d - 2.1 * t);\n        const target = clamp((value - 0.9) / 1.2, 0, 1);\n        level[i] = level[i]! + (target - level[i]!) * ease;\n        const b = level[i]! < 0.03 ? 0 : level[i]!;\n        set[i]!((0.08 + 0.92 * b).toFixed(3));\n      }\n    },\n  };\n}\n\nfunction Matrix({ live }: { live: boolean }) {\n  const ref = useSim(makeMatrix, live);\n  return (\n    <div ref={ref} className={LCD + \" rounded-litt-surface p-[0.16em]\"}>\n      <svg viewBox=\"0 0 55 25\" fill=\"currentColor\" className=\"h-[82%] w-full\">\n        {DOTS.map((i) => (\n          <circle key={i} cx={2.5 + 5 * (i % COLS)} cy={2.5 + 5 * Math.floor(i / COLS)} r=\"1.55\" opacity=\"0.08\" />\n        ))}\n      </svg>\n    </div>\n  );\n}\n\n/* ----------------------------------------------------------------- Chase (running) */\n\nconst WELLS = Array.from({ length: 8 }, (_, i) => i);\n\nfunction makeChase(root: HTMLElement): Sim {\n  const set = qa(root, \"[data-s]\").map((el) => attr(el, \"data-s\"));\n  let t = 0;\n  return {\n    step(dt) {\n      t = (t + dt) % 1.93;\n      const head = t < 1.28 ? Math.floor(t / 0.16) : t < 1.68 ? 8 : -1;\n      for (let i = 0; i < set.length; i++) set[i]!(i === head && head < 8 ? \"head\" : i < head ? \"on\" : \"off\");\n    },\n  };\n}\n\nfunction Chase({ live }: { live: boolean }) {\n  const ref = useSim(makeChase, live);\n  return (\n    <div ref={ref} className={LCD + \" gap-[0.12em]\"}>\n      {WELLS.map((i) => (\n        <span key={i} className=\"grid size-[0.56em] place-items-center rounded-litt-pill [box-shadow:var(--litt-field-shade)]\">\n          <span\n            data-s=\"off\"\n            className=\"size-[0.3em] rounded-litt-pill bg-[color-mix(in_oklab,currentColor_12%,transparent)] data-[s=head]:bg-litt-accent data-[s=head]:shadow-[0_0_0.4em_var(--litt-accent)] data-[s=on]:bg-current\"\n          />\n        </span>\n      ))}\n    </div>\n  );\n}\n\n/* ----------------------------------------------------------------- Reels (writing) */\n\nconst REEL_Y = 11.5;\nconst REELS = [15, 41];\nconst HUB = 3;\nconst FULL = 8.5;\nconst pack = (p: number) => Math.sqrt(HUB * HUB + p * (FULL * FULL - HUB * HUB));\nconst RATE = 1 / 9;\n\nfunction makeReels(root: HTMLElement): Sim {\n  const spin = qa(root, \"[data-spin]\").map((el) => attr(el, \"transform\"));\n  const packs = qa(root, \"[data-pack]\").map((el) => attr(el, \"r\"));\n  const setTape = attr(q(root, \"[data-tape]\"), \"d\");\n  const setLamp = attr(q(root, \"[data-rec]\"), \"opacity\");\n  let p = 0.08;\n  let dir = 1;\n  const angle = [0, 0];\n  return {\n    step(dt) {\n      const rate = RATE * (dir > 0 ? 1 : 3.5);\n      p = clamp(p + dir * rate * dt, 0, 1);\n      if (p >= 1) dir = -1;\n      else if (p <= 0) dir = 1;\n      const r = [pack(1 - p), pack(p)];\n      for (let i = 0; i < 2; i++) {\n        angle[i] = (angle[i]! - dir * ((268 * rate) / r[i]!) * dt * (180 / Math.PI)) % 360;\n        spin[i]!(`rotate(${(Math.round(angle[i]! * 10) / 10).toFixed(1)} ${REELS[i]} ${REEL_Y})`);\n        packs[i]!(r[i]!.toFixed(2));\n      }\n      setTape(`M${(REELS[0]! + 0.35 * r[0]!).toFixed(2)} ${(REEL_Y + 0.94 * r[0]!).toFixed(2)} L23 23.2 L33 23.2 L${(REELS[1]! - 0.35 * r[1]!).toFixed(2)} ${(REEL_Y + 0.94 * r[1]!).toFixed(2)}`);\n      setLamp(dir > 0 ? \"1\" : \"0.15\");\n    },\n  };\n}\n\nfunction Reels({ live }: { live: boolean }) {\n  const ref = useSim(makeReels, live);\n  return (\n    <div ref={ref} className={LCD}>\n      <svg viewBox=\"0 0 56 26\" fill=\"none\" className=\"h-full w-full\">\n        <path data-tape d=\"M18 21 L23 23.2 L33 23.2 L38 21\" stroke=\"currentColor\" strokeWidth=\"0.6\" opacity=\"0.3\" />\n        <rect x=\"26.5\" y=\"22.4\" width=\"3\" height=\"2\" fill=\"currentColor\" opacity=\"0.25\" />\n        {REELS.map((cx) => (\n          <g key={cx} fill=\"currentColor\">\n            <circle cx={cx} cy={REEL_Y} r=\"9.4\" opacity=\"0.03\" />\n            <circle data-pack cx={cx} cy={REEL_Y} r={FULL} opacity=\"0.13\" />\n            <g data-spin transform={`rotate(0 ${cx} ${REEL_Y})`}>\n              <circle cx={cx} cy={REEL_Y} r=\"2.9\" opacity=\"0.2\" />\n              {[0, 120, 240].map((deg) => (\n                <line\n                  key={deg}\n                  x1={cx}\n                  y1={REEL_Y}\n                  x2={cx + 2.6 * Math.cos((deg * Math.PI) / 180)}\n                  y2={REEL_Y + 2.6 * Math.sin((deg * Math.PI) / 180)}\n                  stroke=\"currentColor\"\n                  strokeWidth=\"0.7\"\n                  opacity=\"0.55\"\n                />\n              ))}\n            </g>\n          </g>\n        ))}\n        <circle data-rec cx=\"51.8\" cy=\"4.2\" r=\"1.1\" className=\"fill-litt-accent\" style={{ filter: \"drop-shadow(0 0 1px var(--litt-accent))\" }} />\n      </svg>\n    </div>\n  );\n}\n\n/* ----------------------------------------------------------------- the indicator */\n\n/** What an agent can be doing, each drawn by its own mechanism. */\nexport type AgentPhase = \"thinking\" | \"searching\" | \"reading\" | \"reasoning\" | \"running\" | \"writing\";\n\n/** The phases, in the order the page shows them, with the word a screen reader hears. */\nexport const AGENT_PHASES: readonly { phase: AgentPhase; words: string }[] = [\n  { phase: \"thinking\", words: \"Thinking\" },\n  { phase: \"searching\", words: \"Searching\" },\n  { phase: \"reading\", words: \"Reading\" },\n  { phase: \"reasoning\", words: \"Reasoning\" },\n  { phase: \"running\", words: \"Running\" },\n  { phase: \"writing\", words: \"Writing\" },\n];\n\nconst DRAW: Record<AgentPhase, ComponentType<{ live: boolean }>> = {\n  thinking: Segments,\n  searching: Tuner,\n  reading: Meters,\n  reasoning: Matrix,\n  running: Chase,\n  writing: Reels,\n};\n\n/* The window every mechanism sits in: LCD glass, as an Input's field is. */\nconst LCD =\n  \"relative flex h-full w-full items-center justify-center overflow-hidden rounded-litt-control bg-litt-field text-litt-field-ink \" +\n  \"shadow-[inset_0_0_0_1px_var(--litt-field-line),var(--litt-field-shade)]\";\n\nexport type AgentIndicatorProps = {\n  /** What the agent is doing, which picks the mechanism. A change crossfades to the new one. Leave it out and the window is dark and still. */\n  phase?: AgentPhase | null;\n  /** Holds the frame it is on. Tie it to your stream, so the indicator moves only while tokens arrive. */\n  paused?: boolean;\n  /** The words a screen reader hears, in place of the phase's own (\"Thinking\"). */\n  label?: string;\n  /** Extra classes for the box, such as a font size. The box is 5.6em by 2.6em, so the font size sets its size. */\n  className?: string;\n};\n\nfunction Layer({ phase, live, leaving, fresh }: { phase: AgentPhase; live: boolean; leaving: boolean; fresh: boolean }) {\n  const Draw = DRAW[phase];\n  return (\n    <span\n      aria-hidden=\"true\"\n      data-layer={phase}\n      data-leaving={leaving ? \"\" : undefined}\n      className={\n        \"col-start-1 row-start-1 block h-full min-h-0 w-full min-w-0 transition-[opacity] duration-(--litt-dur-exit-responsive) ease-litt data-[leaving]:opacity-0 \" +\n        (fresh ? \"starting:opacity-0\" : \"\")\n      }\n    >\n      <Draw live={live} />\n    </span>\n  );\n}\n\n/** What an agent is doing, in hardware: one of six mechanisms for the phase, moving only while it is not paused. A Device part: it needs data-material=\"device\" on <html>. */\nexport function AgentIndicator({ phase, paused = false, label, className = \"\" }: AgentIndicatorProps) {\n  const { reduced } = useMotionDial();\n  const box = useRef<HTMLSpanElement>(null);\n  const [visible, setVisible] = useState(true);\n  const [settled, setSettled] = useState(false);\n  const [shown, setShown] = useState(phase);\n  const [leaving, setLeaving] = useState<AgentPhase | null>(null);\n\n  /* A new phase is adjusted while rendering, so the first frame after the change already draws it. */\n  if (phase !== shown) {\n    setLeaving(shown ?? null);\n    setShown(phase);\n  }\n  useEffect(() => {\n    setSettled(true);\n  }, []);\n  useEffect(() => {\n    if (!leaving) return;\n    const id = setTimeout(() => setLeaving(null), 150);\n    return () => clearTimeout(id);\n  }, [leaving]);\n\n  useEffect(() => {\n    const el = box.current;\n    if (!el || typeof IntersectionObserver === \"undefined\") return;\n    const io = new IntersectionObserver(([entry]) => entry && setVisible(entry.isIntersecting));\n    io.observe(el);\n    return () => io.disconnect();\n  }, []);\n\n  const live = !!shown && !paused && !reduced && visible;\n  const words = label ?? AGENT_PHASES.find((p) => p.phase === shown)?.words;\n\n  return (\n    <span\n      ref={box}\n      role=\"status\"\n      data-agent-indicator=\"\"\n      data-phase={shown ?? undefined}\n      className={\"relative inline-grid h-[2.6em] w-[5.6em] shrink-0 grid-cols-[minmax(0,1fr)] grid-rows-[minmax(0,1fr)] align-middle \" + className}\n    >\n      {words && <span className=\"sr-only\">{words}</span>}\n      {leaving && <Layer key={leaving} phase={leaving} live={live} leaving fresh={false} />}\n      {shown ? (\n        <Layer key={shown} phase={shown} live={live} leaving={false} fresh={settled} />\n      ) : (\n        <span aria-hidden=\"true\" className={\"col-start-1 row-start-1 block \" + LCD} />\n      )}\n    </span>\n  );\n}\n",
      "target": "components/litt/agent-indicator.tsx"
    }
  ],
  "docs": "Needs data-material=\"device\" on <html>. Pass the phase and it crossfades between mechanisms; pass paused to hold it. Under reduced motion each holds one frame. It says the phase in words for a screen reader.",
  "meta": {
    "version": "2026.11.08",
    "released": "2026-11-08",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
