{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "streaming-token-settle",
  "title": "Streaming Token Settle",
  "description": "Streaming text where provisional tokens sit off-baseline and a hair rotated like loose letterpress type, then snap into the chase with a stiff spring the instant they're confirmed. A correction slides the old word out sideways as its replacement slides in.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/streaming-token-settle/component.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// LooseType — a streaming-text surface where provisional tokens sit like\n// loose letterpress type (off-baseline, a hair rotated, --ns-muted) and\n// physically lock into the chase the instant they're confirmed (snap to\n// baseline, --foreground, a stiff low-damping spring). A corrected token\n// slides out sideways as its replacement slides in, the freed width\n// collapsing under it so the line reflows like a compositor swapping a\n// sort — not a flash. Pure DOM/CSS transforms, no canvas.\n//\n// The motion IS the data: jitter vs. baseline is the only signal for\n// provisional vs. committed, there's no separate status pill. Distinct from\n// streaming-ink-dry (which encodes elapsed-time freshness via weight/opacity and never\n// snaps) and text-decrypt (a one-shot scramble-to-reveal on a fixed string,\n// no underlying state machine at all).\n// ---------------------------------------------------------------------------\n\ntype Key = string | number;\n\nexport interface LooseTypeToken {\n  /**\n   * Stable identity. Reusing an id updates that token in place (e.g. a\n   * still-provisional token settling toward its final wording without a\n   * full swap). Give a *replacement* token a fresh id at the same array\n   * position to represent a correction — the old id's span slides out as\n   * the new id's span slides in, at that seam.\n   */\n  id: Key;\n  text: string;\n  /** Locked into the chase. Default false (provisional). */\n  committed?: boolean;\n}\n\nexport interface LooseTypeProps {\n  /** Full ordered snapshot of the line's tokens, every render. */\n  tokens: LooseTypeToken[];\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\ntype Status = \"entering\" | \"settled\" | \"removing\";\n\ninterface Item {\n  id: Key;\n  text: string;\n  committed: boolean;\n  jitter: { ty: number; rot: number };\n  status: Status;\n  /** entering: false until the slide-in transition should start */\n  animateIn?: boolean;\n  /** removing: measured px width, captured before the collapse starts */\n  widthPx?: number;\n  /** removing: false until the collapse/slide-out transition should start */\n  collapse?: boolean;\n}\n\nconst ENTER_MS = 260;\nconst EXIT_MS = 260;\nconst COMMIT_MS = 420;\n// A back-out curve is the pure-CSS stand-in for a stiff, low-damping spring:\n// it overshoots past the resting value before settling, same read as a sort\n// dropping into the chase and bouncing once against its neighbors.\nconst SPRING_EASE = \"cubic-bezier(0.34, 1.56, 0.64, 1)\";\n\n// Deterministic per-token offset so a token's tilt/lift never rerolls across\n// re-renders — \"seeded per token\", not per paint. Two independent floats are\n// pulled from one FNV-1a hash by mixing it a second time for the pair.\nfunction seededJitter(id: Key): { ty: number; rot: number } {\n  const s = String(id);\n  let h = 0x811c9dc5;\n  for (let i = 0; i < s.length; i++) {\n    h ^= s.charCodeAt(i);\n    h = Math.imul(h, 0x01000193);\n  }\n  const h2 = Math.imul(h ^ 0x9e3779b9, 0x2545f491);\n  const ty = (((h >>> 0) % 2001) / 1000 - 1) * 1; // -1..1 px\n  const rot = (((h2 >>> 0) % 1201) / 1000 - 0.6) * 1; // -0.6..0.6 deg\n  return { ty, rot };\n}\n\nfunction makeEntering(t: LooseTypeToken): Item {\n  return {\n    id: t.id,\n    text: t.text,\n    committed: !!t.committed,\n    jitter: seededJitter(t.id),\n    status: \"entering\",\n    animateIn: false,\n  };\n}\n\nfunction makeSettled(t: LooseTypeToken): Item {\n  return {\n    id: t.id,\n    text: t.text,\n    committed: !!t.committed,\n    jitter: seededJitter(t.id),\n    status: \"settled\",\n  };\n}\n\nfunction carryOver(prev: Item, t: LooseTypeToken): Item {\n  return { ...prev, text: t.text, committed: !!t.committed };\n}\n\n// Walks the previous render list against the new snapshot in order. A\n// surviving id is carried over in place; an id that vanished becomes (or\n// stays) a \"removing\" ghost planted exactly where it was; a brand-new id\n// is inserted as \"entering\" right before whatever surviving token it now\n// precedes. For the common case — append-only growth, or a single tail\n// token swapped for its correction — this lands the ghost and its\n// replacement adjacent, at the seam, which is the whole point.\nfunction diff(prev: Item[], tokens: LooseTypeToken[]): Item[] {\n  const incoming = new Set(tokens.map((t) => t.id));\n  const next: Item[] = [];\n  let ti = 0;\n\n  for (const p of prev) {\n    if (incoming.has(p.id)) {\n      while (ti < tokens.length && tokens[ti]!.id !== p.id) {\n        next.push(makeEntering(tokens[ti]!));\n        ti++;\n      }\n      if (ti < tokens.length && tokens[ti]!.id === p.id) {\n        next.push(carryOver(p, tokens[ti]!));\n        ti++;\n      }\n    } else if (p.status === \"removing\") {\n      next.push(p); // already ghosted, don't restart its timer/measurement\n    } else {\n      next.push({ ...p, status: \"removing\", widthPx: undefined, collapse: false });\n    }\n  }\n  while (ti < tokens.length) {\n    next.push(makeEntering(tokens[ti]!));\n    ti++;\n  }\n  return next;\n}\n\nexport function LooseType({ tokens, className = \"\" }: LooseTypeProps) {\n  const [items, setItemsState] = useState<Item[]>(() => tokens.map(makeSettled));\n  const itemsRef = useRef(items);\n  // synchronous init so a mount with provisional tokens already present under\n  // prefers-reduced-motion never paints the jittered branch on the first frame\n  const reducedRef = useRef(\n    typeof window !== \"undefined\" &&\n      typeof window.matchMedia === \"function\" &&\n      window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n  );\n  const nodesRef = useRef(new Map<Key, HTMLSpanElement>());\n  const measuredRef = useRef(new Set<Key>());\n  const flippedRef = useRef(new Set<Key>());\n\n  const setItems = useCallback((next: Item[]) => {\n    itemsRef.current = next;\n    setItemsState(next);\n  }, []);\n\n  useEffect(() => {\n    reducedRef.current = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n  }, []);\n\n  const sig = useMemo(\n    () => tokens.map((t) => `${t.id}:${t.committed ? 1 : 0}:${t.text}`).join(\"\u0001\"),\n    [tokens]\n  );\n\n  useEffect(() => {\n    if (reducedRef.current) {\n      setItems(tokens.map(makeSettled));\n      return;\n    }\n    setItems(diff(itemsRef.current, tokens));\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [sig]);\n\n  // Entering tokens: flip from the pre-slide style to the animated one a\n  // frame after mount, so the browser actually observes a style change to\n  // transition from (setting the \"final\" style on the same paint as mount\n  // would just render there, no motion).\n  useLayoutEffect(() => {\n    if (reducedRef.current) return;\n    const pending = items.filter((it) => it.status === \"entering\" && !it.animateIn && !flippedRef.current.has(it.id));\n    if (!pending.length) return;\n    for (const it of pending) flippedRef.current.add(it.id);\n    const raf = requestAnimationFrame(() => {\n      setItems(\n        itemsRef.current.map((it) =>\n          it.status === \"entering\" && pending.some((p) => p.id === it.id) ? { ...it, animateIn: true } : it\n        )\n      );\n    });\n    // If this rAF gets cancelled (e.g. the sibling \"removing\" measurement\n    // effect fires a synchronous setItems in the same layout-effect flush,\n    // before the browser ever paints a frame), the flip never actually\n    // happened — un-mark these ids so the next effect pass retries them.\n    // Without this, a token that entered in the same tick a correction was\n    // ghosted (exactly the provisional-correction seam) got permanently\n    // stuck at opacity:0/transition:none: never animated in, never fired\n    // the transitionend that settles it — invisible forever.\n    return () => {\n      cancelAnimationFrame(raf);\n      for (const it of pending) flippedRef.current.delete(it.id);\n    };\n  }, [items, setItems]);\n\n  // Removing tokens: measure the natural width first (so the collapse has a\n  // real px start point — \"auto\" can't be transitioned), then flip to the\n  // collapsed target a frame later.\n  useLayoutEffect(() => {\n    if (reducedRef.current) return;\n    const toMeasure = items.filter((it) => it.status === \"removing\" && it.widthPx === undefined && !measuredRef.current.has(it.id));\n    if (!toMeasure.length) return;\n    for (const it of toMeasure) measuredRef.current.add(it.id);\n    const widths = new Map<Key, number>();\n    for (const it of toMeasure) {\n      const node = nodesRef.current.get(it.id);\n      widths.set(it.id, node ? node.offsetWidth : 0);\n    }\n    setItems(\n      itemsRef.current.map((it) => (widths.has(it.id) ? { ...it, widthPx: widths.get(it.id) } : it))\n    );\n  }, [items, setItems]);\n\n  useEffect(() => {\n    if (reducedRef.current) return;\n    const toCollapse = items.filter((it) => it.status === \"removing\" && it.widthPx !== undefined && !it.collapse);\n    if (!toCollapse.length) return;\n    const raf = requestAnimationFrame(() => {\n      setItems(\n        itemsRef.current.map((it) =>\n          it.status === \"removing\" && toCollapse.some((p) => p.id === it.id) ? { ...it, collapse: true } : it\n        )\n      );\n    });\n    return () => cancelAnimationFrame(raf);\n  }, [items, setItems]);\n\n  const onGhostTransitionEnd = useCallback(\n    (id: Key, e: React.TransitionEvent<HTMLSpanElement>) => {\n      if (e.propertyName !== \"max-width\") return;\n      measuredRef.current.delete(id);\n      nodesRef.current.delete(id);\n      setItems(itemsRef.current.filter((it) => it.id !== id));\n    },\n    [setItems]\n  );\n\n  const onEnterTransitionEnd = useCallback(\n    (id: Key, e: React.TransitionEvent<HTMLSpanElement>) => {\n      if (e.propertyName !== \"opacity\") return;\n      flippedRef.current.delete(id);\n      setItems(itemsRef.current.map((it) => (it.id === id && it.status === \"entering\" ? { ...it, status: \"settled\" } : it)));\n    },\n    [setItems]\n  );\n\n  const committedText = tokens\n    .filter((t) => t.committed)\n    .map((t) => t.text)\n    .join(\" \");\n\n  return (\n    <div className={`ns-streaming-token-settle ${className}`}>\n      <div aria-live=\"polite\" className=\"sr-only\">\n        {committedText}\n      </div>\n      <div aria-hidden=\"true\" className=\"flex flex-wrap items-baseline\">\n        {items.map((it) => {\n          const reduced = reducedRef.current;\n          const base: React.CSSProperties = {\n            display: \"inline-block\",\n            marginRight: \"0.28em\",\n            marginBottom: \"0.15em\",\n          };\n\n          if (reduced) {\n            return (\n              <span\n                key={it.id}\n                className={it.committed ? \"ns-streaming-token-settle-token text-foreground\" : \"ns-streaming-token-settle-token ns-streaming-token-settle-provisional text-ns-muted\"}\n                style={base}\n              >\n                {it.text}\n              </span>\n            );\n          }\n\n          if (it.status === \"entering\") {\n            const style: React.CSSProperties = it.animateIn\n              ? {\n                  ...base,\n                  transform: `translateX(0) translateY(${it.jitter.ty}px) rotate(${it.jitter.rot}deg)`,\n                  opacity: 1,\n                  transitionProperty: \"transform, opacity\",\n                  transitionDuration: `${ENTER_MS}ms`,\n                  transitionTimingFunction: \"cubic-bezier(0.16, 1, 0.3, 1)\",\n                }\n              : {\n                  ...base,\n                  transform: \"translateX(0.4em)\",\n                  opacity: 0,\n                  transition: \"none\",\n                };\n            return (\n              <span\n                key={it.id}\n                ref={(node) => {\n                  if (node) nodesRef.current.set(it.id, node);\n                }}\n                className={it.committed ? \"ns-streaming-token-settle-token text-foreground\" : \"ns-streaming-token-settle-token ns-streaming-token-settle-provisional text-ns-muted\"}\n                style={style}\n                onTransitionEnd={(e) => onEnterTransitionEnd(it.id, e)}\n              >\n                {it.text}\n              </span>\n            );\n          }\n\n          if (it.status === \"removing\") {\n            const style: React.CSSProperties = it.collapse\n              ? {\n                  ...base,\n                  maxWidth: 0,\n                  marginRight: 0,\n                  opacity: 0,\n                  transform: \"translateX(-0.4em)\",\n                  overflow: \"hidden\",\n                  whiteSpace: \"nowrap\",\n                  transitionProperty: \"max-width, margin-right, opacity, transform\",\n                  transitionDuration: `${EXIT_MS}ms`,\n                  transitionTimingFunction: \"ease-out\",\n                }\n              : {\n                  ...base,\n                  maxWidth: it.widthPx !== undefined ? `${it.widthPx}px` : undefined,\n                  opacity: 1,\n                  transform: \"translateX(0)\",\n                  overflow: \"hidden\",\n                  whiteSpace: \"nowrap\",\n                  transition: \"none\",\n                };\n            return (\n              <span\n                key={it.id}\n                ref={(node) => {\n                  if (node) nodesRef.current.set(it.id, node);\n                }}\n                className={it.committed ? \"ns-streaming-token-settle-token text-foreground\" : \"ns-streaming-token-settle-token ns-streaming-token-settle-provisional text-ns-muted\"}\n                style={style}\n                onTransitionEnd={(e) => onGhostTransitionEnd(it.id, e)}\n              >\n                {it.text}\n              </span>\n            );\n          }\n\n          // settled: the resting spring lives here permanently, so a\n          // provisional -> committed flip animates purely off the\n          // `committed` boolean with no extra phase to schedule.\n          return (\n            <span\n              key={it.id}\n              className={it.committed ? \"ns-streaming-token-settle-token text-foreground\" : \"ns-streaming-token-settle-token ns-streaming-token-settle-provisional text-ns-muted\"}\n              style={{\n                ...base,\n                transform: it.committed ? \"translateY(0) rotate(0deg)\" : `translateY(${it.jitter.ty}px) rotate(${it.jitter.rot}deg)`,\n                transitionProperty: \"transform, color\",\n                transitionDuration: `${COMMIT_MS}ms, 200ms`,\n                transitionTimingFunction: `${SPRING_EASE}, ease-out`,\n              }}\n            >\n              {it.text}\n            </span>\n          );\n        })}\n      </div>\n      <style>{CSS}</style>\n    </div>\n  );\n}\n\nconst CSS = `\n.ns-streaming-token-settle-provisional{\n  text-decoration: none;\n}\n@media (prefers-reduced-motion: reduce){\n  .ns-streaming-token-settle-provisional{\n    text-decoration: underline;\n    text-decoration-style: dotted;\n    text-underline-offset: 3px;\n  }\n}\n`;\n",
      "type": "registry:ui",
      "target": "components/ui/streaming-token-settle.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)"
    },
    "light": {
      "ns-muted": "#4d4d4d"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "text",
      "streaming",
      "transcription",
      "state-machine",
      "accessibility"
    ],
    "instruction": "<LooseType tokens={LooseTypeToken[]}> renders a line of streaming text as a real provisional-vs-committed state machine, not a decorative flourish: pass the full ordered token snapshot every render, where each token is { id, text, committed? }. A provisional token (committed: false, the default) renders muted and sits off its baseline by a token-seeded ±1px translateY and ±0.6deg rotation — like loose type not yet locked into the chase — so a still frame reads at a glance as 'may still change' without any color-only signal to miss. The instant a token's committed flips to true it snaps to baseline, full --foreground, with a stiff, low-damping spring (a CSS back-out easing that overshoots slightly before settling, ~420ms) — no flash, the motion itself is the confirmation. To represent a correction (the model or ASR revising a word), give the replacement a fresh id at the same array position instead of editing the old token's text in place: the old id's span slides out sideways (translateX -0.4em, fades) while the new id's span slides in from the opposite side (translateX +0.4em to 0), and the space the old token occupied is width-tweened back to zero as it exits so the rest of the line reflows under it smoothly, like a compositor pulling and reseating a sort, rather than jumping. Reusing an existing id just updates that token's text/committed flag in place with no swap animation. Pure DOM + CSS transforms on real <span> elements, zero canvas, zero dependencies. Accessibility is a single channel by design: the entire visual row is aria-hidden, and a screen reader instead gets one aria-live=polite region holding only the committed text, joined with single spaces — provisional wording is never exposed to assistive tech, so a reader is never told something that might still be revised, and because the region's content is recomputed as one string on every commit or correction, a multi-token correction announces as one coalesced update rather than word-by-word churn. prefers-reduced-motion drops the jitter, the slide, and the spring entirely: provisional tokens are told apart from committed ones purely by --ns-muted color plus a dotted underline, both applied and removed instantly with no transform or transition at all, so the component stays fully legible and non-distracting under reduced motion rather than just slowing down. Distinct from streaming-ink-dry (which encodes elapsed-time freshness via variable-font weight/opacity/blur ramping toward settled, with no revision concept and nothing ever un-arrives) and from text-decrypt (a one-shot monospace scramble-to-decode entrance on a fixed string, no ongoing state at all)."
  },
  "type": "registry:ui"
}