{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "loader-ink-blob",
  "title": "Loader Ink Blob",
  "description": "A small canvas-2D assistant-state indicator: a soft ink blob traced through 72 points around a circle, each perturbed by one of six distinct motion signatures (idle, thinking, listening, speaking, success, error), with a checkmark or X that strokes itself in for the two settled states.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/loader-ink-blob/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// ThinkingGlyph — a small canvas-2D \"assistant state\" indicator: a soft ink\n// blob traced through N points sampled around a circle, each point's radius\n// perturbed by a per-state motion function. No WebGL, no SVG filters — flat\n// canvas fills and strokes only, so it renders identically across browsers.\n//\n// Six states, six distinct motion signatures (not six lookups into the same\n// noise field):\n//   idle      — one slow sine wobble, gentle breathing scale\n//   thinking  — three additive sine bands at different frequencies/phases\n//               plus tiny deterministic per-point jitter (turbulent surface)\n//   listening — the blob itself barely moves; a ring emits from its edge and\n//               expands/fades on a fixed period (sonar pulse)\n//   speaking  — a 6-lobe standing wave rides the outline, amplitude\n//               modulated by a slow envelope (a wobbling waveform, not audio\n//               reactive — no external signal)\n//   success   — wobble amplitude eases to zero (blob settles into a near\n//               circle) and a checkmark strokes itself in, timed from the\n//               moment `state` became \"success\"\n//   error     — a short decaying horizontal shake plays every ~900ms and an\n//               X strokes itself in, timed from the moment `state` became\n//               \"error\"\n//\n// Ink comes from --foreground/--ns-muted for the blob and --ns-accent/--success/\n// --error for the sonar ring, checkmark and X respectively, all read via\n// getComputedStyle at mount and re-read on a MutationObserver watching\n// documentElement's class attribute. The rAF loop pauses on\n// visibilitychange and under prefers-reduced-motion, drawing one static\n// settled frame instead. Backing store is dpr-clamped to 2.\n// ---------------------------------------------------------------------------\n\nexport type ThinkingGlyphState = \"idle\" | \"thinking\" | \"listening\" | \"speaking\" | \"success\" | \"error\";\n\nexport interface ThinkingGlyphProps {\n  /** Which motion signature to show. @default \"idle\" */\n  state?: ThinkingGlyphState;\n  /** Rendered diameter in CSS px. @default 48 */\n  size?: number;\n  /** Animation speed multiplier. @default 1 */\n  speed?: number;\n  /** Freezes the current frame. @default false */\n  paused?: boolean;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n  /** inline styles merged onto the root element */\n  style?: React.CSSProperties;\n}\n\nconst POINTS = 72;\nconst TWO_PI = Math.PI * 2;\n\nconst STATE_LABEL: Record<ThinkingGlyphState, string> = {\n  idle: \"idle\",\n  thinking: \"thinking\",\n  listening: \"listening\",\n  speaking: \"speaking\",\n  success: \"done\",\n  error: \"error\",\n};\n\nfunction hashP(i: number): number {\n  const h = Math.sin(i * 91.345 + 12.9) * 43758.5453;\n  return h - Math.floor(h);\n}\n\nfunction parseHex(raw: string): [number, number, number] | null {\n  const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(raw.trim());\n  if (!m) return null;\n  let h = m[1];\n  if (h.length === 3) h = h.split(\"\").map((c) => c + c).join(\"\");\n  const n = parseInt(h, 16);\n  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n}\n\nexport function ThinkingGlyph({\n  state = \"idle\",\n  size = 48,\n  speed = 1,\n  paused = false,\n  className = \"\",\n  style,\n}: ThinkingGlyphProps) {\n  const canvasRef = useRef<HTMLCanvasElement | null>(null);\n  const stateRef = useRef(state);\n  const speedRef = useRef(speed);\n  const pausedRef = useRef(paused);\n  const now = () => (typeof performance !== \"undefined\" ? performance.now() : 0);\n  const stateSinceRef = useRef(now());\n\n  if (stateRef.current !== state) {\n    stateRef.current = state;\n    stateSinceRef.current = now();\n  }\n  speedRef.current = speed;\n  pausedRef.current = paused;\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    let raf = 0;\n    let running = false;\n    let dpr = 1;\n\n    let fg = \"rgba(23,23,23,1)\";\n    let fgSoft = \"rgba(23,23,23,0.14)\";\n    let mutedStyle = \"rgba(77,77,77,1)\";\n    let accentStyle = \"#006bff\";\n    let successStyle = \"#47a447\";\n    let errorStyle = \"#ea001d\";\n\n    const readInk = () => {\n      const cs = getComputedStyle(document.documentElement);\n      const fgHex = cs.getPropertyValue(\"--foreground\").trim();\n      const rgb = parseHex(fgHex) ?? [23, 23, 23];\n      fg = `rgba(${rgb[0]},${rgb[1]},${rgb[2]},0.92)`;\n      fgSoft = `rgba(${rgb[0]},${rgb[1]},${rgb[2]},0.14)`;\n      mutedStyle = cs.getPropertyValue(\"--ns-muted\").trim() || \"#4d4d4d\";\n      accentStyle = cs.getPropertyValue(\"--ns-accent\").trim() || \"#006bff\";\n      successStyle = cs.getPropertyValue(\"--success\").trim() || \"#47a447\";\n      errorStyle = cs.getPropertyValue(\"--error\").trim() || \"#ea001d\";\n    };\n    readInk();\n\n    const resize = () => {\n      dpr = Math.min(window.devicePixelRatio || 1, 2);\n      canvas.width = Math.round(size * dpr);\n      canvas.height = Math.round(size * dpr);\n      canvas.style.width = `${size}px`;\n      canvas.style.height = `${size}px`;\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n    };\n    resize();\n\n    const cx = size / 2;\n    const cy = size / 2;\n    const baseR = size * 0.32;\n\n    // per-point jitter phase, stable across frames (deterministic hash, not\n    // Math.random — reproducible and free of a seeded-PRNG dependency)\n    const jitter = new Float32Array(POINTS);\n    for (let i = 0; i < POINTS; i++) jitter[i] = hashP(i) * TWO_PI;\n\n    const radiusFor = (i: number, angle: number, t: number, st: ThinkingGlyphState, since: number): number => {\n      const age = (performance.now() - since) / 1000;\n      switch (st) {\n        case \"idle\":\n          return baseR * (1 + 0.04 * Math.sin(angle * 3 + t * 1.1));\n        case \"thinking\":\n          return (\n            baseR *\n            (1 +\n              0.05 * Math.sin(angle * 5 + t * 2.3) +\n              0.035 * Math.sin(angle * 8 - t * 3.1 + 1.7) +\n              0.018 * Math.sin(angle * 13 + t * 4.3 + jitter[i]))\n          );\n        case \"listening\":\n          return baseR * (1 + 0.05 * Math.sin(t * 3.2));\n        case \"speaking\": {\n          const envelope = 0.6 + 0.4 * Math.sin(t * 1.4);\n          return baseR * (1 + 0.11 * envelope * Math.sin(angle * 6 + t * 5.2));\n        }\n        case \"success\": {\n          const settle = Math.min(1, age / 0.5);\n          const wobble = (1 - settle) * 0.05 * Math.sin(angle * 4 + t * 2);\n          return baseR * (1 + wobble);\n        }\n        case \"error\": {\n          const cyclePos = (t * 1.05) % 1;\n          const burst = cyclePos < 0.35 ? Math.exp(-cyclePos * 14) : 0;\n          return baseR * (1 + 0.03 * Math.sin(angle * 4 + t * 3) + 0.01 * burst);\n        }\n        default:\n          return baseR;\n      }\n    };\n\n    const drawBlob = (t: number, st: ThinkingGlyphState, since: number) => {\n      let shakeX = 0;\n      if (st === \"error\") {\n        const cyclePos = (t * 1.05) % 1;\n        if (cyclePos < 0.35) {\n          const decay = Math.exp(-cyclePos * 14);\n          shakeX = Math.sin(cyclePos * 90) * 3 * decay;\n        }\n      }\n\n      ctx.save();\n      ctx.translate(shakeX, 0);\n\n      const pts: [number, number][] = [];\n      for (let i = 0; i < POINTS; i++) {\n        const angle = (i / POINTS) * TWO_PI;\n        const r = radiusFor(i, angle, t, st, since);\n        pts.push([cx + Math.cos(angle) * r, cy + Math.sin(angle) * r]);\n      }\n\n      ctx.beginPath();\n      const first = pts[0];\n      ctx.moveTo((first[0] + pts[POINTS - 1][0]) / 2, (first[1] + pts[POINTS - 1][1]) / 2);\n      for (let i = 0; i < POINTS; i++) {\n        const p = pts[i];\n        const next = pts[(i + 1) % POINTS];\n        const midX = (p[0] + next[0]) / 2;\n        const midY = (p[1] + next[1]) / 2;\n        ctx.quadraticCurveTo(p[0], p[1], midX, midY);\n      }\n      ctx.closePath();\n\n      ctx.fillStyle = fgSoft;\n      ctx.fill();\n      ctx.lineWidth = Math.max(1, size * 0.03);\n      ctx.strokeStyle = st === \"thinking\" ? mutedStyle : fg;\n      ctx.stroke();\n      ctx.restore();\n    };\n\n    const drawSonar = (t: number) => {\n      const period = 1.2;\n      const phase = (t % period) / period;\n      const r = baseR * (1 + phase * 0.9);\n      const alpha = (1 - phase) * 0.45;\n      if (alpha <= 0.01) return;\n      ctx.beginPath();\n      ctx.arc(cx, cy, r, 0, TWO_PI);\n      ctx.strokeStyle = accentStyle;\n      ctx.globalAlpha = alpha;\n      ctx.lineWidth = Math.max(1, size * 0.02);\n      ctx.stroke();\n      ctx.globalAlpha = 1;\n    };\n\n    const drawCheck = (since: number) => {\n      const age = (performance.now() - since) / 1000;\n      const p = Math.min(1, age / 0.4);\n      if (p <= 0) return;\n      const x0 = cx - baseR * 0.42;\n      const y0 = cy + baseR * 0.02;\n      const x1 = cx - baseR * 0.08;\n      const y1 = cy + baseR * 0.34;\n      const x2 = cx + baseR * 0.46;\n      const y2 = cy - baseR * 0.32;\n      ctx.beginPath();\n      ctx.strokeStyle = successStyle;\n      ctx.lineWidth = Math.max(1.5, size * 0.06);\n      ctx.lineCap = \"round\";\n      ctx.lineJoin = \"round\";\n      ctx.moveTo(x0, y0);\n      if (p <= 0.5) {\n        const seg = p / 0.5;\n        ctx.lineTo(x0 + (x1 - x0) * seg, y0 + (y1 - y0) * seg);\n      } else {\n        ctx.lineTo(x1, y1);\n        const seg = (p - 0.5) / 0.5;\n        ctx.lineTo(x1 + (x2 - x1) * seg, y1 + (y2 - y1) * seg);\n      }\n      ctx.stroke();\n    };\n\n    const drawX = (since: number) => {\n      const age = (performance.now() - since) / 1000;\n      const p = Math.min(1, age / 0.35);\n      if (p <= 0) return;\n      const k = baseR * 0.36;\n      ctx.strokeStyle = errorStyle;\n      ctx.lineWidth = Math.max(1.5, size * 0.06);\n      ctx.lineCap = \"round\";\n      ctx.beginPath();\n      ctx.moveTo(cx - k, cy - k);\n      ctx.lineTo(cx - k + (2 * k) * Math.min(1, p * 2), cy - k + (2 * k) * Math.min(1, p * 2));\n      ctx.stroke();\n      if (p > 0.5) {\n        const seg = (p - 0.5) / 0.5;\n        ctx.beginPath();\n        ctx.moveTo(cx + k, cy - k);\n        ctx.lineTo(cx + k - (2 * k) * seg, cy - k + (2 * k) * seg);\n        ctx.stroke();\n      }\n    };\n\n    const draw = (nowMs: number) => {\n      ctx.clearRect(0, 0, size, size);\n      const st = stateRef.current;\n      const since = stateSinceRef.current;\n      const t = (nowMs / 1000) * speedRef.current;\n      if (st === \"listening\") drawSonar(t);\n      drawBlob(t, st, since);\n      if (st === \"success\") drawCheck(since);\n      if (st === \"error\") drawX(since);\n    };\n\n    const loop = (t: number) => {\n      draw(t);\n      raf = requestAnimationFrame(loop);\n    };\n    const wake = () => {\n      if (running) return;\n      running = true;\n      raf = requestAnimationFrame(loop);\n    };\n    const sleep = () => {\n      cancelAnimationFrame(raf);\n      running = false;\n    };\n\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    let reduced = mq.matches;\n    const applyMode = () => {\n      if (reduced || pausedRef.current) {\n        sleep();\n        draw(performance.now());\n      } else {\n        wake();\n      }\n    };\n    const onMq = () => {\n      reduced = mq.matches;\n      applyMode();\n    };\n    mq.addEventListener(\"change\", onMq);\n    applyMode();\n\n    let pollId = 0;\n    let lastPaused = pausedRef.current;\n    const poll = () => {\n      if (pausedRef.current !== lastPaused) {\n        lastPaused = pausedRef.current;\n        applyMode();\n      }\n      pollId = window.setTimeout(poll, 120);\n    };\n    poll();\n\n    const onVis = () => {\n      if (document.hidden) sleep();\n      else if (!reduced && !pausedRef.current) wake();\n    };\n    document.addEventListener(\"visibilitychange\", onVis);\n\n    const themeObserver = new MutationObserver(() => {\n      readInk();\n      if (reduced || pausedRef.current) draw(performance.now());\n    });\n    themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n\n    return () => {\n      mq.removeEventListener(\"change\", onMq);\n      document.removeEventListener(\"visibilitychange\", onVis);\n      themeObserver.disconnect();\n      window.clearTimeout(pollId);\n      sleep();\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [size]);\n\n  return (\n    <div\n      role=\"img\"\n      aria-label={`Assistant is ${STATE_LABEL[state]}`}\n      className={className}\n      style={{ width: size, height: size, display: \"inline-block\", ...style }}\n    >\n      <canvas ref={canvasRef} aria-hidden=\"true\" />\n    </div>\n  );\n}\n\nThinkingGlyph.displayName = \"ThinkingGlyph\";\n",
      "type": "registry:ui",
      "target": "components/ui/loader-ink-blob.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff",
      "error": "#ea001d",
      "success": "#2d7a2d"
    },
    "dark": {
      "ns-muted": "#8f8f8f",
      "error": "#ff6369",
      "success": "#47a447"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "loader",
      "status",
      "canvas",
      "ai",
      "chat",
      "indicator",
      "spinner"
    ],
    "instruction": "A small fixed-size (default 48 CSS px, plain `size` prop) canvas-2D indicator for an AI assistant's current state, driven by a `state` prop: 'idle' | 'thinking' | 'listening' | 'speaking' | 'success' | 'error'. Internally it traces a smooth closed blob through 72 points sampled evenly around a circle of radius 0.32×size, each point's radius perturbed by a state-specific function of angle and time (no shared noise field across states — each is its own formula): idle is one slow sine wobble; thinking sums three sine bands at different frequencies/phases plus a tiny deterministic per-point jitter (hash-seeded, not Math.random, so it's reproducible) for a turbulent surface; listening keeps the blob almost still while a ring emits from its edge and expands/fades on a fixed 1.2s period (sonar pulse) in the --ns-accent token; speaking rides a 6-lobe standing wave around the outline under a slow amplitude envelope; success eases the wobble amplitude to zero over 0.5s (settling into a near-circle) while a checkmark strokes itself in over 0.4s in the --success token, timed from the moment `state` became 'success' (tracked via a ref updated during render when the prop changes, not in an effect, so the timer never lags a rapid state change); error plays a short exponentially-decaying horizontal shake burst every ~950ms while an X strokes itself in over 0.35s in the --error token, timed the same way. The blob itself is filled at 14% alpha and stroked at ~92% alpha in the --foreground token (--ns-muted while thinking, to read as 'processing' rather than 'settled'). All ink is read via getComputedStyle(document.documentElement) at mount and re-read on a MutationObserver watching documentElement's class attribute. The rAF loop pauses on document visibilitychange and under prefers-reduced-motion (draws one static settled frame instead) and can be frozen on its current frame via a `paused` prop (checked through a lightweight ref poll so toggling it doesn't tear down the canvas). `speed` scales every state's internal clock. Backing store is dpr-clamped to 2. The wrapper carries role='img' and an aria-label naming the current state ('Assistant is thinking', etc.) since this is a display-only status glyph with no interactive control — screen reader users get the state as text instead of inferring it from motion."
  },
  "type": "registry:ui"
}