{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "event-stream-vapor",
  "title": "Event Stream Vapor",
  "description": "A Wilson cloud chamber for your event stream: each real event condenses into a soft vapor trail (angle by category, length/brightness by magnitude) that diffuses away over seconds, with a paired Geist Mono log so the same stream reads in words.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/loud/event-stream-vapor/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// CloudChamber — ambient awareness for a live event stream, styled as a\n// Wilson cloud chamber: each real event condenses into a vapor trail, angle\n// bucketed by category, length/brightness set by magnitude, heavy events\n// forking short branch tracks. A quiet chamber vs one full of long streaks\n// tells an on-call engineer something before any threshold fires.\n//\n// MECHANISM: no persistent ink buffer. Every event pushes a small batch of\n// \"stamps\" (rx/ry fraction position, birth time, per-particle tau, peak\n// alpha) into a plain array; each animation frame fully clears the canvas\n// and redraws every live stamp from scratch at\n// alpha = peak * exp(-age / tau), pruning anything decayed past ~5*tau. This\n// is a deliberate departure from a destination-out accumulation buffer:\n// destination-out at the low per-frame alpha a 6-10s fade requires rounds\n// dst*(1-a) to no-op once dst*alpha < 0.5 in the 8-bit backing store, which\n// stalls the fade at a permanent low haze instead of returning to empty —\n// the same residue failure mode noted on command-palette-orbit. Redrawing\n// from an explicit, analytically-decaying list has no such floor: once every\n// stamp prunes out, the canvas is provably transparent again. A soft round\n// sprite (radial gradient baked from the CURRENT --foreground token) is\n// blitted per stamp with `lighter` compositing, so overlapping fresh\n// particles brighten into a hot streak core while the tails stay soft —\n// brightness is the only intensity channel, never hue.\n//\n// Track shape: origin is a random point inside the inner 64% of the box;\n// direction is `angleForCategory` (a small canonical table for common\n// categories, a deterministic hash bucket for anything else — this never\n// throws for an unknown category string); distance covers speed * ~400ms,\n// speed and per-particle brightness/radius/lifetime all driven by\n// `magnitude`. Positions are stored as box-relative fractions and converted\n// to px at DRAW time using the CURRENT box size, so an in-flight resize\n// never distorts an already-spawned track. magnitude >= 0.68 also forks 1-2\n// short branch tracks off a point partway along the primary path, at a\n// randomized angle offset — the \"heavy event ionizes harder\" read.\n//\n// A11Y: the canvas is aria-hidden. The paired Geist Mono legend\n// (role=\"log\", aria-live=\"polite\") is not decorative — it is the same\n// event stream in words, name/category/magnitude, and it is what a screen\n// reader user gets instead of the chamber. New rows are batched into one\n// flush every `flushMs` so a burst of events produces one live-region\n// announcement, not one per event. Under prefers-reduced-motion the canvas\n// is not rendered at all; the legend takes the full width and gains a row\n// of small static per-event tick marks (angle + brightness, zero motion) —\n// the same information, still legible, never a frozen animation.\n//\n// Every drawn color is read from --foreground via getComputedStyle at mount\n// and re-derived (sprite rebuilt) on every documentElement class change, so\n// theme flips repaint correctly. The rAF loop pauses via IntersectionObserver\n// while offscreen. Canvas 2D, zero dependencies.\n// ---------------------------------------------------------------------------\n\nexport interface ChamberEvent {\n  /** stable, unique id — used to detect \"new\" events across renders */\n  id: string;\n  /** short human label, shown in the legend and announced */\n  name: string;\n  /** free-form category string; drives the track's angle bucket */\n  category: string;\n  /** 0..1 severity/size — drives speed, length, brightness, branching. default 0.4 */\n  magnitude?: number;\n  /** epoch ms, display only */\n  timestamp?: number;\n}\n\nexport interface CloudChamberProps {\n  /** the event stream so far — append new items, ids already seen are ignored */\n  events: ChamberEvent[];\n  /** accessible name for the log region */\n  label?: string;\n  /** override or extend the category -> angle (degrees) table */\n  categoryAngles?: Record<string, number>;\n  /** rows kept in the legend / considered for tick marks. default 30 */\n  maxLegendItems?: number;\n  /** batching window (ms) for legend rows + the live-region announcement. default 350 */\n  flushMs?: number;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\ntype RGB = [number, number, number];\n\ninterface Stamp {\n  rx: number;\n  ry: number;\n  r: number;\n  born: number; // performance.now() ms — may be in the future (staggered reveal)\n  tau: number; // seconds\n  peak: number; // alpha ceiling 0..1\n}\n\nconst MAX_STAMPS = 700;\nconst TRAVEL_MS = 400;\n\n// canonical angle table (degrees, canvas convention: 0 = right, 90 = down).\n// anything not listed here falls back to a deterministic hash bucket, so\n// no category string is ever \"unhandled\".\nconst CANONICAL_ANGLES: Record<string, number> = {\n  deploy: -50,\n  release: -50,\n  error: 100,\n  crash: 100,\n  purchase: 15,\n  payment: 15,\n  warning: 135,\n  signal: -125,\n};\n\nfunction hashString(s: string): number {\n  let h = 2166136261;\n  for (let i = 0; i < s.length; i++) {\n    h ^= s.charCodeAt(i);\n    h = Math.imul(h, 16777619);\n  }\n  return h >>> 0;\n}\n\nfunction angleForCategory(category: string, overrides?: Record<string, number>): number {\n  if (overrides && overrides[category] !== undefined) return overrides[category];\n  const canon = CANONICAL_ANGLES[category.toLowerCase()];\n  if (canon !== undefined) return canon;\n  return (hashString(category) % 24) * 15;\n}\n\nconst clamp01 = (v: number) => Math.min(1, Math.max(0, v));\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t;\nconst deg2rad = (d: number) => (d * Math.PI) / 180;\n\nfunction magnitudeOf(e: ChamberEvent): number {\n  return clamp01(e.magnitude ?? 0.4);\n}\n\nfunction parseHex(v: string): RGB | null {\n  const m = v.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);\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\nfunction readForeground(el: HTMLElement): RGB {\n  const cs = getComputedStyle(el);\n  return parseHex(cs.getPropertyValue(\"--foreground\")) ?? [237, 237, 237];\n}\n\n// baked once per token derivation: a soft round dot, tinted from the token\n// so nothing downstream ever touches a color literal.\nfunction buildSprite(rgb: RGB): HTMLCanvasElement {\n  const size = 48;\n  const c = document.createElement(\"canvas\");\n  c.width = size;\n  c.height = size;\n  const g = c.getContext(\"2d\");\n  if (!g) return c;\n  const [r, gr, b] = rgb;\n  const grad = g.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);\n  grad.addColorStop(0, `rgba(${r},${gr},${b},1)`);\n  grad.addColorStop(0.45, `rgba(${r},${gr},${b},0.5)`);\n  grad.addColorStop(1, `rgba(${r},${gr},${b},0)`);\n  g.fillStyle = grad;\n  g.fillRect(0, 0, size, size);\n  return c;\n}\n\nfunction computeBranch(\n  ox: number,\n  oy: number,\n  ex: number,\n  ey: number,\n  angle: number,\n  magnitude: number\n) {\n  const originF = lerp(0.3, 0.65, Math.random());\n  const bx = lerp(ox, ex, originF);\n  const by = lerp(oy, ey, originF);\n  const sign = Math.random() < 0.5 ? -1 : 1;\n  const branchAngle = angle + sign * deg2rad(35 + Math.random() * 35);\n  const branchMag = magnitude * 0.55;\n  const branchSpeed = lerp(90, 340, branchMag);\n  const branchTravelMs = TRAVEL_MS * 0.55;\n  const branchDist = (branchSpeed * branchTravelMs) / 1000;\n  const bex = bx + Math.cos(branchAngle) * branchDist;\n  const bey = by + Math.sin(branchAngle) * branchDist;\n  const count = Math.max(3, Math.round(lerp(3, 8, branchMag)));\n  const tau = lerp(2.2, 3.4, branchMag);\n  const delay = originF * TRAVEL_MS;\n  return { bx, by, bex, bey, count, tau, delay, branchTravelMs, branchMag };\n}\n\nfunction spawnTrack(\n  ev: ChamberEvent,\n  stamps: Stamp[],\n  dims: { w: number; h: number },\n  categoryAngles: Record<string, number> | undefined,\n  nowMs: number\n) {\n  const w0 = dims.w > 0 ? dims.w : 400;\n  const h0 = dims.h > 0 ? dims.h : 220;\n  const magnitude = magnitudeOf(ev);\n  const angle = deg2rad(angleForCategory(ev.category, categoryAngles));\n  const speed = lerp(90, 340, magnitude);\n  const dist = (speed * TRAVEL_MS) / 1000;\n  const ox = lerp(w0 * 0.18, w0 * 0.82, Math.random());\n  const oy = lerp(h0 * 0.18, h0 * 0.82, Math.random());\n  const ex = ox + Math.cos(angle) * dist;\n  const ey = oy + Math.sin(angle) * dist;\n  const count = Math.max(6, Math.round(lerp(6, 18, magnitude)));\n  const tau = lerp(2.2, 3.4, magnitude);\n\n  for (let i = 0; i < count; i++) {\n    const f = count === 1 ? 0 : i / (count - 1);\n    stamps.push({\n      rx: lerp(ox, ex, f) / w0,\n      ry: lerp(oy, ey, f) / h0,\n      r: lerp(1.1, 2.6, magnitude) * (0.85 + Math.random() * 0.3),\n      born: nowMs + f * TRAVEL_MS,\n      tau,\n      peak: lerp(0.35, 0.95, magnitude),\n    });\n  }\n\n  // heavier events ionize harder: fork 1-2 short branch tracks\n  if (magnitude >= 0.68) {\n    const branchCount = magnitude >= 0.88 ? 2 : 1;\n    for (let b = 0; b < branchCount; b++) {\n      const branch = computeBranch(ox, oy, ex, ey, angle, magnitude);\n      for (let i = 0; i < branch.count; i++) {\n        const f = branch.count === 1 ? 0 : i / (branch.count - 1);\n        stamps.push({\n          rx: lerp(branch.bx, branch.bex, f) / w0,\n          ry: lerp(branch.by, branch.bey, f) / h0,\n          r: lerp(1.0, 2.0, branch.branchMag) * (0.85 + Math.random() * 0.3),\n          born: nowMs + branch.delay + f * branch.branchTravelMs,\n          tau: branch.tau,\n          peak: lerp(0.3, 0.75, branch.branchMag),\n        });\n      }\n    }\n  }\n\n  if (stamps.length > MAX_STAMPS) stamps.splice(0, stamps.length - MAX_STAMPS);\n}\n\n// -------------------------------------------------------- event log hook ---\n// Diffs `events` by id, batches genuinely-new arrivals into one flush every\n// `flushMs` — a burst of events produces one legend update and one\n// aria-live announcement, not one per event. Shared by both the canvas and\n// reduced-motion paths (the throttle is an a11y/legend concern, independent\n// of whether the chamber itself is rendered).\nfunction useEventLog(events: ChamberEvent[], flushMs: number, maxItems: number) {\n  const seenRef = useRef<Set<string>>(new Set());\n  const pendingRef = useRef<ChamberEvent[]>([]);\n  const timerRef = useRef<number | undefined>(undefined);\n  const [rows, setRows] = useState<ChamberEvent[]>([]);\n  const [liveText, setLiveText] = useState(\"\");\n\n  useEffect(() => {\n    const fresh: ChamberEvent[] = [];\n    for (const e of events) {\n      if (!seenRef.current.has(e.id)) {\n        seenRef.current.add(e.id);\n        fresh.push(e);\n      }\n    }\n    if (fresh.length === 0) return;\n    pendingRef.current.push(...fresh);\n    if (timerRef.current === undefined) {\n      timerRef.current = window.setTimeout(() => {\n        const batch = pendingRef.current;\n        pendingRef.current = [];\n        timerRef.current = undefined;\n        setRows((prev) => [...batch].reverse().concat(prev).slice(0, maxItems));\n        setLiveText(\n          batch.length === 1\n            ? `${batch[0]!.name}, ${batch[0]!.category}, magnitude ${Math.round(magnitudeOf(batch[0]!) * 100)} percent`\n            : `${batch.length} events: ${batch.map((e) => e.name).join(\", \")}`\n        );\n      }, flushMs);\n    }\n  }, [events, flushMs, maxItems]);\n\n  useEffect(() => () => window.clearTimeout(timerRef.current), []);\n\n  return { rows, liveText };\n}\n\nfunction useReducedMotionPref(): boolean {\n  const [reduced, setReduced] = useState(false);\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    setReduced(mq.matches);\n    const onChange = (e: MediaQueryListEvent) => setReduced(e.matches);\n    mq.addEventListener(\"change\", onChange);\n    return () => mq.removeEventListener(\"change\", onChange);\n  }, []);\n  return reduced;\n}\n\nexport function CloudChamber({\n  events,\n  label = \"Live event chamber\",\n  categoryAngles,\n  maxLegendItems = 30,\n  flushMs = 350,\n  className = \"h-96\",\n}: CloudChamberProps) {\n  const reduced = useReducedMotionPref();\n  const { rows, liveText } = useEventLog(events, flushMs, maxLegendItems);\n\n  const containerRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const stampsRef = useRef<Stamp[]>([]);\n  const dimsRef = useRef({ w: 0, h: 0 });\n\n  // spawn a track the instant a genuinely new event lands — this must not\n  // be throttled by the legend batching above, the vapor should condense in\n  // step with the real stream.\n  const spawnSeenRef = useRef<Set<string>>(new Set());\n  useEffect(() => {\n    if (reduced) return;\n    const nowMs = performance.now();\n    for (const e of events) {\n      if (spawnSeenRef.current.has(e.id)) continue;\n      spawnSeenRef.current.add(e.id);\n      spawnTrack(e, stampsRef.current, dimsRef.current, categoryAngles, nowMs);\n    }\n  }, [events, categoryAngles, reduced]);\n\n  useEffect(() => {\n    if (reduced) return;\n    const root = containerRef.current;\n    const canvas = canvasRef.current;\n    if (!root || !canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    let w = 0;\n    let h = 0;\n    let dpr = 1;\n    const resize = () => {\n      const rect = root.getBoundingClientRect();\n      w = rect.width;\n      h = rect.height;\n      dpr = Math.min(2, window.devicePixelRatio || 1);\n      canvas.width = Math.max(1, Math.round(w * dpr));\n      canvas.height = Math.max(1, Math.round(h * dpr));\n      dimsRef.current = { w, h };\n    };\n    resize();\n\n    let fg = readForeground(root);\n    let sprite = buildSprite(fg);\n    const mo = new MutationObserver(() => {\n      fg = readForeground(root);\n      sprite = buildSprite(fg);\n    });\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n\n    let raf = 0;\n    let visible = true;\n\n    const draw = () => {\n      if (w < 1 || h < 1) return;\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, w, h);\n\n      const now = performance.now();\n      const stamps = stampsRef.current;\n      const alive: Stamp[] = [];\n      ctx.globalCompositeOperation = \"lighter\";\n      for (const s of stamps) {\n        const age = (now - s.born) / 1000;\n        if (age < 0) {\n          alive.push(s); // not yet revealed — stays pending\n          continue;\n        }\n        if (age > s.tau * 5) continue; // fully decayed, drop\n        const alpha = s.peak * Math.exp(-age / s.tau);\n        if (alpha > 0.008) {\n          const x = s.rx * w;\n          const y = s.ry * h;\n          ctx.globalAlpha = Math.min(1, alpha);\n          ctx.drawImage(sprite, x - s.r, y - s.r, s.r * 2, s.r * 2);\n        }\n        alive.push(s);\n      }\n      ctx.globalAlpha = 1;\n      ctx.globalCompositeOperation = \"source-over\";\n      stampsRef.current = alive;\n    };\n\n    const loop = () => {\n      draw();\n      raf = visible ? requestAnimationFrame(loop) : 0;\n    };\n\n    const ro = new ResizeObserver(() => resize());\n    ro.observe(root);\n\n    const io = new IntersectionObserver(\n      (entries) => {\n        const entry = entries[0];\n        if (!entry) return;\n        visible = entry.isIntersecting;\n        if (visible && !raf) raf = requestAnimationFrame(loop);\n      },\n      { threshold: 0 }\n    );\n    io.observe(root);\n\n    raf = requestAnimationFrame(loop);\n\n    return () => {\n      cancelAnimationFrame(raf);\n      raf = 0;\n      ro.disconnect();\n      io.disconnect();\n      mo.disconnect();\n    };\n  }, [reduced]);\n\n  return (\n    <div className={`flex w-full gap-4 ${className}`}>\n      {!reduced && (\n        <div\n          ref={containerRef}\n          aria-hidden\n          className=\"relative min-w-0 flex-1 overflow-hidden rounded-md border border-border bg-background\"\n        >\n          <canvas ref={canvasRef} aria-hidden className=\"absolute inset-0 h-full w-full\" />\n        </div>\n      )}\n\n      <div className={`flex min-w-0 flex-col ${reduced ? \"w-full\" : \"w-64 shrink-0\"}`}>\n        {reduced && (\n          <div aria-hidden className=\"mb-3 flex flex-wrap items-end gap-2\">\n            {rows.length === 0 ? (\n              <span className=\"font-mono text-[10px] text-ns-muted\">no ticks yet</span>\n            ) : (\n              rows\n                .slice(0, 40)\n                .reverse()\n                .map((e) => {\n                  const m = magnitudeOf(e);\n                  const pct = Math.round(30 + m * 70);\n                  return (\n                    <span\n                      key={e.id}\n                      title={`${e.name} — ${e.category}`}\n                      className=\"inline-block w-[2px] rounded-full\"\n                      style={{\n                        height: `${14 + m * 16}px`,\n                        transform: `rotate(${angleForCategory(e.category, categoryAngles) - 90}deg)`,\n                        backgroundColor: `color-mix(in srgb, var(--foreground) ${pct}%, var(--ns-muted))`,\n                      }}\n                    />\n                  );\n                })\n            )}\n          </div>\n        )}\n\n        <div\n          role=\"log\"\n          aria-live=\"polite\"\n          aria-atomic=\"false\"\n          aria-label={label}\n          className=\"min-h-0 flex-1 overflow-y-auto rounded-md border border-border bg-background p-3 font-mono text-[11px] leading-relaxed\"\n        >\n          {rows.length === 0 ? (\n            <p className=\"text-ns-muted\">Chamber quiet — no events yet.</p>\n          ) : (\n            <ul className=\"space-y-1\">\n              {rows.map((e) => (\n                <li key={e.id} className=\"truncate text-ns-muted\">\n                  <span className=\"text-foreground\">{e.name}</span> · {e.category} ·{\" \"}\n                  {Math.round(magnitudeOf(e) * 100)}%\n                </li>\n              ))}\n            </ul>\n          )}\n          <span className=\"sr-only\">{liveText}</span>\n        </div>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/event-stream-vapor.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)"
    },
    "light": {
      "ns-muted": "#4d4d4d"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "loud",
    "tags": [
      "monitoring",
      "event-stream",
      "status",
      "canvas",
      "ambient",
      "dashboard",
      "aria-live",
      "mono"
    ],
    "instruction": "A Wilson cloud chamber styled ambient-awareness widget for a live event stream, laid out as a canvas chamber (flex-1) beside a fixed-width Geist Mono legend. Consumer owns the data: pass `events` ({id, name, category, magnitude?, timestamp?}[]) as an append-only array — the component diffs by id every render and only ever reacts to ids it has not seen before, so re-renders with the same array are inert. MECHANISM: each new event immediately seeds a short track — origin is a random point inside the inner 64% of the box, direction comes from `angleForCategory` (a small canonical table: deploy/release ~-50deg, error/crash ~100deg, purchase/payment ~15deg, warning ~135deg, signal ~-125deg; any other category string falls back to a deterministic hash bucket, so nothing is ever unhandled), and distance covers speed*~400ms where speed and the per-particle brightness/radius/lifetime all scale with `magnitude` (0..1, default 0.4). Positions are stored as box-relative fractions and converted to pixels at DRAW time using the CURRENT box size, so an in-flight resize never distorts an already-spawned track. magnitude >= 0.68 also forks 1-2 short branch tracks off a random point partway along the primary path at a randomized angle offset (2 branches once magnitude >= 0.88) — the 'heavier events ionize harder' read. RENDERING: there is deliberately no persistent destination-out accumulation buffer. Every event pushes a small batch of stamps (fractional position, birth time, per-particle tau, peak alpha) into a plain capped array (700 max, oldest dropped first); every animation frame fully clears the canvas and redraws every live stamp from scratch at alpha = peak * exp(-age/tau) with 'lighter' compositing (so overlapping fresh particles brighten into a hot streak core while tails stay soft), pruning anything past ~5*tau. This is the fix for the destination-out fade this brief calls out: repeated low-alpha destination-out on an 8-bit backing store stalls once dst*alpha drops below the rounding floor, leaving a permanent low haze instead of returning to empty; redrawing from an explicit, analytically-decaying list has no such floor, so a quiet stream provably returns to a fully transparent, quiet chamber. Per-particle tau is 2.2-3.4s (branches 2.2-3.4s scaled off a lower magnitude), so a track's mist is visibly gone within roughly 6-10s of its last stamp. Brightness is the ONLY intensity channel — strictly monochrome, derived from --foreground via getComputedStyle at mount and re-baked into a small radial-gradient sprite on every documentElement class change, so both themes render correctly (dark ink on a light chamber in light mode is correct, not a bug). ACCESSIBILITY: the canvas is aria-hidden — the real interface for assistive tech is the paired legend, a role=log aria-live=polite region carrying event name/category/magnitude as plain Geist Mono text, labelled via the `label` prop. New arrivals are diffed against the same seen-id set but batched into ONE flush every `flushMs` (default 350) so a burst of events produces a single legend update and a single live-region announcement rather than one per event; this throttle only affects the legend/announcement, never the canvas, which reacts to a genuinely new event immediately. REDUCED MOTION: prefers-reduced-motion (checked live via a matchMedia change listener, not just once at mount) replaces the whole chamber — no canvas node is rendered at all — with the legend taking the full width plus a row of small static per-event tick marks (rotated to the event's category angle, sized/brightened by magnitude, zero motion, built from the same throttled row list) above it, so the same information stays legible without any animation. ENGINEERING: rAF loop pauses via IntersectionObserver while the chamber is scrolled offscreen; ResizeObserver keeps the canvas backing store correct (DPR clamped to 2); every observer, timer and rAF handle is torn down on unmount. Zero dependencies. Props: events, label, categoryAngles (override/extend the category->angle degree table), maxLegendItems (default 30), flushMs (default 350), className (default 'h-96', sets the widget's height)."
  },
  "type": "registry:ui"
}