{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "hero-chart-recorder",
  "title": "Hero Chart Recorder",
  "description": "A chart-recorder hero: ruled paper feeds left at fixed px/s while a mechanical pen chases the live value through a deliberately underdamped spring, so a spike overshoots, quivers, and settles — and that tremor is stamped permanently into the trace.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/loud/hero-chart-recorder/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// PenLag — a chart-recorder hero. Ruled paper scrolls left at a fixed px/s\n// while a mechanical pen chases the live value through a deliberately\n// underdamped spring (k=120, c=8): a spike overshoots, quivers, and settles.\n// The trace buffer only ever APPENDS what the pen actually drew — the\n// tremor, once inked, is never recomputed, only its screen position shifts\n// as the paper feeds under a fixed writing point. The pen arm and needle\n// carriage are DOM overlays (crisp) on top of the canvas trace (raster).\n// ---------------------------------------------------------------------------\n\ntype Vec3 = [number, number, number];\n\nfunction parseColor(raw: string): Vec3 | null {\n  const s = raw.trim();\n  if (s.startsWith(\"#\")) {\n    const hex = s.slice(1);\n    if (hex.length === 3) {\n      const r = parseInt(hex[0]! + hex[0], 16);\n      const g = parseInt(hex[1]! + hex[1], 16);\n      const b = parseInt(hex[2]! + hex[2], 16);\n      return Number.isNaN(r + g + b) ? null : [r, g, b];\n    }\n    if (hex.length >= 6) {\n      const r = parseInt(hex.slice(0, 2), 16);\n      const g = parseInt(hex.slice(2, 4), 16);\n      const b = parseInt(hex.slice(4, 6), 16);\n      return Number.isNaN(r + g + b) ? null : [r, g, b];\n    }\n    return null;\n  }\n  const m = s.match(/rgba?\\(\\s*([\\d.]+)[,\\s]+([\\d.]+)[,\\s]+([\\d.]+)/);\n  return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;\n}\n\ntype Sample = { t: number; v: number };\n\n// underdamped pen spring: zeta = C / (2*sqrt(K)) ≈ 0.365 — overshoots, rings,\n// settles. Not a prop: this specific mechanical character IS the component.\nconst SPRING_K = 120;\nconst SPRING_C = 8;\n\nconst RAIL_INSET = 22; // px reserved at the right edge for the pen carriage\nconst PAD_Y = 14; // vertical drawing padding inside the strip\nconst TICK_INTERVAL_S = 5; // seconds between minor time ticks\nconst RULE_SPACING = 26; // px between horizontal ruled lines\nconst CURSOR_STEP_PX = 26; // keyboard scrub step\nconst REDUCED_TICK_MS = 480; // redraw cadence under prefers-reduced-motion\nconst ANNOUNCE_INTERVAL_MS = 1500;\nconst TREND_LOOKBACK_MS = 12000;\nconst SCRUB_ANNOUNCE_THROTTLE_MS = 150;\n\nfunction formatAge(ms: number): string {\n  const s = Math.round(Math.max(0, ms) / 1000);\n  if (s < 60) return `-${s}s`;\n  const m = Math.floor(s / 60);\n  const r = s % 60;\n  return `-${m}m${r.toString().padStart(2, \"0\")}s`;\n}\n\nfunction formatWindowLabel(rangeMs: number): string {\n  const min = Math.round(rangeMs / 60000);\n  if (min < 1) return `${Math.round(rangeMs / 1000)}-second`;\n  return `${min}-minute`;\n}\n\nexport interface PenLagProps {\n  /** the true, unlagged live value the pen chases */\n  value: number;\n  /** fixed vertical scale — recorder paper is calibrated, not auto-ranging */\n  min?: number;\n  /** upper bound of the fixed vertical scale, paired with `min` */\n  max?: number;\n  /** unit suffix appended to the readout */\n  unit?: string;\n  /** used in the mono readout and the aria-live summary */\n  label?: string;\n  /** paper feed speed, px/s */\n  speed?: number;\n  /** window used for the aria-live range/trend summary */\n  rangeMs?: number;\n  /** formats the value for the mono readout and aria-live summary */\n  formatValue?: (value: number) => string;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nexport function PenLag({\n  value,\n  min = 0,\n  max = 500,\n  unit = \"ms\",\n  label = \"Response time\",\n  speed = 60,\n  rangeMs = 5 * 60 * 1000,\n  formatValue,\n  className = \"h-72\",\n}: PenLagProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const readoutRef = useRef<HTMLSpanElement>(null);\n  const trendRef = useRef<HTMLSpanElement>(null);\n  const rangeRef = useRef<HTMLSpanElement>(null);\n  const statusRef = useRef<HTMLSpanElement>(null);\n  const armRef = useRef<HTMLDivElement>(null);\n  const carriageRef = useRef<HTMLDivElement>(null);\n  const cursorLineRef = useRef<HTMLDivElement>(null);\n  const cursorLabelRef = useRef<HTMLDivElement>(null);\n\n  const valueRef = useRef(value);\n  valueRef.current = value;\n  const minRef = useRef(min);\n  minRef.current = min;\n  const maxRef = useRef(max);\n  maxRef.current = max;\n  const unitRef = useRef(unit);\n  unitRef.current = unit;\n  const labelRef = useRef(label);\n  labelRef.current = label;\n  const formatRef = useRef(formatValue);\n  formatRef.current = formatValue;\n  const speedRef = useRef(speed);\n  speedRef.current = speed;\n  const rangeMsRef = useRef(rangeMs);\n  rangeMsRef.current = rangeMs;\n\n  useEffect(() => {\n    const root = rootRef.current;\n    const canvas = canvasRef.current;\n    const readout = readoutRef.current;\n    const trendEl = trendRef.current;\n    const rangeEl = rangeRef.current;\n    const status = statusRef.current;\n    const arm = armRef.current;\n    const carriage = carriageRef.current;\n    const cursorLine = cursorLineRef.current;\n    const cursorLabel = cursorLabelRef.current;\n    if (\n      !root ||\n      !canvas ||\n      !readout ||\n      !trendEl ||\n      !rangeEl ||\n      !status ||\n      !arm ||\n      !carriage ||\n      !cursorLine ||\n      !cursorLabel\n    ) {\n      return;\n    }\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    const baseLabel = `${labelRef.current} chart recorder strip`;\n    root.setAttribute(\"aria-label\", baseLabel);\n\n    // -- token-derived ink: read at mount, re-derived on theme change -------\n    let fg: Vec3 = [237, 237, 237];\n    let bd: Vec3 = [46, 46, 46];\n    let mu: Vec3 = [143, 143, 143];\n    const derive = () => {\n      const cs = getComputedStyle(document.documentElement);\n      fg = parseColor(cs.getPropertyValue(\"--foreground\")) ?? fg;\n      bd = parseColor(cs.getPropertyValue(\"--border\")) ?? bd;\n      mu = parseColor(cs.getPropertyValue(\"--ns-muted\")) ?? mu;\n    };\n    derive();\n\n    const formatDisplay = (v: number) =>\n      formatRef.current\n        ? formatRef.current(v)\n        : `${Math.round(v)}${unitRef.current}`;\n\n    // -- hot-path state: locals only, never React state ---------------------\n    let w = 0;\n    let h = 0;\n    let dpr = 1;\n    let raf = 0;\n    let reducedInterval: ReturnType<typeof setInterval> | undefined;\n    let announceInterval: ReturnType<typeof setInterval> | undefined;\n    let last = 0;\n    let penValue = valueRef.current;\n    let penVel = 0;\n    const buf: Sample[] = [];\n    let reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n      .matches;\n    let paused = false;\n\n    let cursorX = -1; // px on the strip, -1 = inactive\n    let lastScrubAnnounce = 0;\n    let lastAnnouncedRounded = Number.NaN;\n    let lastAnnouncedTrend = \"\";\n\n    const plotWidth = () => Math.max(0, w - RAIL_INSET);\n\n    const yFor = (v: number) => {\n      const span = maxRef.current - minRef.current;\n      const t = span > 1e-9 ? (v - minRef.current) / span : 0.5;\n      const clamped = Math.max(-0.1, Math.min(1.1, t));\n      const y = PAD_Y + (1 - clamped) * (h - PAD_Y * 2);\n      return Math.max(1, Math.min(h - 1, y));\n    };\n\n    const xForAge = (ageMs: number) => plotWidth() - (ageMs / 1000) * speedRef.current;\n\n    const pruneBuffer = () => {\n      const maxSamples = Math.ceil((rangeMsRef.current / 1000) * 70) + 200;\n      if (buf.length > maxSamples + 300) {\n        buf.splice(0, buf.length - maxSamples);\n      }\n    };\n\n    const pushSample = (t: number) => {\n      buf.push({ t, v: penValue });\n      pruneBuffer();\n    };\n\n    const draw = (now: number) => {\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, w, h);\n      if (w <= 0 || h <= 0) return;\n      const pw = plotWidth();\n\n      // ruled paper — horizontal lines are translation-invariant, fixed\n      ctx.strokeStyle = `rgba(${bd[0]},${bd[1]},${bd[2]},0.55)`;\n      ctx.lineWidth = 1;\n      const firstY = h % RULE_SPACING;\n      for (let y = firstY; y < h; y += RULE_SPACING) {\n        ctx.beginPath();\n        ctx.moveTo(0, Math.round(y) + 0.5);\n        ctx.lineTo(w, Math.round(y) + 0.5);\n        ctx.stroke();\n      }\n\n      // minor time ticks — scroll with the paper (time-derived, no drift)\n      const tickSpacing = speedRef.current * TICK_INTERVAL_S;\n      if (tickSpacing > 1) {\n        const phase = reduced ? 0 : (now / 1000) * speedRef.current % tickSpacing;\n        ctx.strokeStyle = `rgba(${mu[0]},${mu[1]},${mu[2]},0.4)`;\n        ctx.lineWidth = 1;\n        for (let x = pw - phase; x > -tickSpacing; x -= tickSpacing) {\n          ctx.beginPath();\n          ctx.moveTo(Math.round(x) + 0.5, h - 9);\n          ctx.lineTo(Math.round(x) + 0.5, h);\n          ctx.stroke();\n        }\n      }\n\n      // carriage housing margin — a faint divider at the writing point\n      ctx.strokeStyle = `rgba(${bd[0]},${bd[1]},${bd[2]},0.7)`;\n      ctx.beginPath();\n      ctx.moveTo(Math.round(pw) + 0.5, 0);\n      ctx.lineTo(Math.round(pw) + 0.5, h);\n      ctx.stroke();\n\n      // trace — walk the buffer newest→oldest, stop once off the left edge.\n      // every point drawn here was appended once and never recomputed; only\n      // its x (a pure function of age) changes frame to frame.\n      const visibleAgeMs = (pw / Math.max(1, speedRef.current)) * 1000;\n      ctx.strokeStyle = `rgb(${fg[0]},${fg[1]},${fg[2]})`;\n      ctx.lineWidth = 1.5;\n      ctx.lineJoin = \"round\";\n      ctx.lineCap = \"round\";\n      ctx.beginPath();\n      let started = false;\n      for (let i = buf.length - 1; i >= 0; i--) {\n        const s = buf[i];\n        if (!s) continue;\n        const age = now - s.t;\n        if (age > visibleAgeMs + 40) break;\n        const x = xForAge(age);\n        const y = yFor(s.v);\n        if (!started) {\n          ctx.moveTo(x, y);\n          started = true;\n        } else {\n          ctx.lineTo(x, y);\n        }\n      }\n      if (started) ctx.stroke();\n    };\n\n    const updateCarriage = () => {\n      const pw = plotWidth();\n      const nibY = yFor(penValue);\n      const railX = pw;\n      carriage.style.transform = `translate(${(railX - 5).toFixed(1)}px, ${(nibY - 5).toFixed(1)}px)`;\n\n      const pivotX = w - 4;\n      const pivotY = h / 2;\n      const dx = railX - pivotX;\n      const dy = nibY - pivotY;\n      const len = Math.sqrt(dx * dx + dy * dy);\n      const angle = Math.atan2(dy, dx);\n      arm.style.left = `${pivotX}px`;\n      arm.style.top = `${pivotY}px`;\n      arm.style.width = `${len.toFixed(1)}px`;\n      arm.style.transform = `rotate(${angle}rad)`;\n    };\n\n    const findNearest = (targetT: number, now: number): Sample | null => {\n      const pw = plotWidth();\n      const visibleAgeMs = (pw / Math.max(1, speedRef.current)) * 1000;\n      let best: Sample | null = null;\n      let bestDiff = Infinity;\n      for (let i = buf.length - 1; i >= 0; i--) {\n        const s = buf[i];\n        if (!s) continue;\n        const age = now - s.t;\n        if (age > visibleAgeMs + 2000) break;\n        const diff = Math.abs(s.t - targetT);\n        if (diff < bestDiff) {\n          bestDiff = diff;\n          best = s;\n        } else if (diff > bestDiff + 400) {\n          break;\n        }\n      }\n      return best;\n    };\n\n    const updateCursor = (now: number) => {\n      if (cursorX < 0) {\n        cursorLine.style.opacity = \"0\";\n        cursorLabel.style.opacity = \"0\";\n        return;\n      }\n      const pw = plotWidth();\n      const ageMs = ((pw - cursorX) / Math.max(1, speedRef.current)) * 1000;\n      const targetT = now - ageMs;\n      const best = findNearest(targetT, now);\n      if (!best) {\n        cursorLine.style.opacity = \"0\";\n        cursorLabel.style.opacity = \"0\";\n        return;\n      }\n      cursorLine.style.opacity = \"1\";\n      cursorLine.style.transform = `translateX(${cursorX.toFixed(1)}px)`;\n      const readingAge = formatAge(now - best.t);\n      const readingVal = formatDisplay(best.v);\n      cursorLabel.style.opacity = \"1\";\n      const labelW = 90;\n      const lx = Math.max(0, Math.min(w - labelW, cursorX - labelW / 2));\n      cursorLabel.style.transform = `translateX(${lx.toFixed(1)}px)`;\n      cursorLabel.textContent = `${readingAge} · ${readingVal}`;\n      root.setAttribute(\n        \"aria-label\",\n        `${baseLabel}, ${readingAge}: ${readingVal}`\n      );\n      if (now - lastScrubAnnounce > SCRUB_ANNOUNCE_THROTTLE_MS) {\n        lastScrubAnnounce = now;\n        status.textContent = `${labelRef.current} at ${readingAge}: ${readingVal}`;\n      }\n    };\n\n    const computeSummary = (now: number) => {\n      const roundedValue = Math.round(valueRef.current);\n      let rangeMin = Infinity;\n      let rangeMax = -Infinity;\n      const windowStart = now - rangeMsRef.current;\n      for (let i = 0; i < buf.length; i++) {\n        const s = buf[i];\n        if (!s || s.t < windowStart) continue;\n        if (s.v < rangeMin) rangeMin = s.v;\n        if (s.v > rangeMax) rangeMax = s.v;\n      }\n      if (!Number.isFinite(rangeMin)) rangeMin = valueRef.current;\n      if (!Number.isFinite(rangeMax)) rangeMax = valueRef.current;\n\n      const baselineT = now - TREND_LOOKBACK_MS;\n      let baseline: Sample | null = null;\n      for (let i = 0; i < buf.length; i++) {\n        const s = buf[i];\n        if (!s) continue;\n        if (s.t >= baselineT) {\n          baseline = s;\n          break;\n        }\n      }\n      const span = maxRef.current - minRef.current;\n      const threshold = Math.max(2, span * 0.02);\n      let trend = \"steady\";\n      if (baseline) {\n        const diff = valueRef.current - baseline.v;\n        if (diff > threshold) trend = \"rising\";\n        else if (diff < -threshold) trend = \"falling\";\n      }\n      return { roundedValue, rangeMin, rangeMax, trend };\n    };\n\n    const runAnnounce = (force: boolean) => {\n      const now = performance.now();\n      const summary = computeSummary(now);\n      trendEl.textContent = summary.trend === \"steady\" ? \"· steady\" : `· ${summary.trend}`;\n      rangeEl.textContent = `${formatWindowLabel(rangeMsRef.current)} ${formatDisplay(\n        summary.rangeMin\n      )}–${formatDisplay(summary.rangeMax)}`;\n      if (cursorX >= 0) return; // an active scrub reading takes priority\n      const changed =\n        force ||\n        Number.isNaN(lastAnnouncedRounded) ||\n        Math.abs(summary.roundedValue - lastAnnouncedRounded) >=\n          Math.max(2, (maxRef.current - minRef.current) * 0.03) ||\n        summary.trend !== lastAnnouncedTrend;\n      if (changed) {\n        lastAnnouncedRounded = summary.roundedValue;\n        lastAnnouncedTrend = summary.trend;\n        status.textContent = `${labelRef.current}, currently ${formatDisplay(\n          valueRef.current\n        )}, ${summary.trend}, ${formatWindowLabel(\n          rangeMsRef.current\n        )} range ${formatDisplay(summary.rangeMin)}–${formatDisplay(\n          summary.rangeMax\n        )}`;\n      }\n    };\n\n    const stepOnce = (now: number, dt: number) => {\n      if (!reduced) {\n        const target = valueRef.current;\n        const accel = -SPRING_K * (penValue - target) - SPRING_C * penVel;\n        penVel += accel * dt;\n        penValue += penVel * dt;\n      } else {\n        penValue = valueRef.current;\n        penVel = 0;\n      }\n      pushSample(now);\n      draw(now);\n      updateCarriage();\n      if (cursorX >= 0) updateCursor(now);\n      readout.textContent = formatDisplay(valueRef.current);\n    };\n\n    const rafLoop = (now: number) => {\n      const dt = Math.min(0.05, last === 0 ? 1 / 60 : (now - last) / 1000);\n      last = now;\n      stepOnce(now, dt);\n      if (!paused) raf = requestAnimationFrame(rafLoop);\n    };\n\n    const stopLoops = () => {\n      if (raf) cancelAnimationFrame(raf);\n      raf = 0;\n      last = 0;\n      if (reducedInterval) clearInterval(reducedInterval);\n      reducedInterval = undefined;\n    };\n\n    const startLoops = () => {\n      stopLoops();\n      if (paused) return;\n      if (reduced) {\n        stepOnce(performance.now(), 0);\n        reducedInterval = setInterval(() => {\n          stepOnce(performance.now(), 0);\n        }, REDUCED_TICK_MS);\n      } else {\n        raf = requestAnimationFrame(rafLoop);\n      }\n    };\n\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      draw(performance.now());\n      updateCarriage();\n      if (cursorX >= 0) {\n        cursorX = Math.min(cursorX, plotWidth());\n        updateCursor(performance.now());\n      }\n    };\n\n    resize();\n    startLoops();\n    announceInterval = setInterval(() => runAnnounce(false), ANNOUNCE_INTERVAL_MS);\n    runAnnounce(true);\n\n    // -- pointer / keyboard scrub --------------------------------------------\n    const onMove = (e: PointerEvent) => {\n      const rect = root.getBoundingClientRect();\n      cursorX = Math.max(0, Math.min(plotWidth(), e.clientX - rect.left));\n      updateCursor(performance.now());\n    };\n    const onLeave = () => {\n      if (cursorX < 0) return;\n      cursorX = -1;\n      root.setAttribute(\"aria-label\", baseLabel);\n      updateCursor(performance.now());\n      runAnnounce(true);\n    };\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key !== \"ArrowLeft\" && e.key !== \"ArrowRight\") return;\n      e.preventDefault();\n      if (cursorX < 0) {\n        cursorX = plotWidth();\n      } else {\n        const step = e.key === \"ArrowLeft\" ? -CURSOR_STEP_PX : CURSOR_STEP_PX;\n        cursorX = Math.max(0, Math.min(plotWidth(), cursorX + step));\n      }\n      updateCursor(performance.now());\n    };\n    root.addEventListener(\"pointermove\", onMove);\n    root.addEventListener(\"pointerdown\", onMove);\n    root.addEventListener(\"pointerleave\", onLeave);\n    root.addEventListener(\"keydown\", onKey);\n    root.addEventListener(\"blur\", onLeave);\n\n    const ro = new ResizeObserver(resize);\n    ro.observe(root);\n\n    const onThemeChange = () => {\n      derive();\n      draw(performance.now());\n    };\n    const mo = new MutationObserver(onThemeChange);\n    mo.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\", \"data-theme\"],\n    });\n    const colorScheme = window.matchMedia(\"(prefers-color-scheme: dark)\");\n    colorScheme.addEventListener(\"change\", onThemeChange);\n\n    const reducedMq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    const onReducedChange = () => {\n      reduced = reducedMq.matches;\n      startLoops();\n    };\n    reducedMq.addEventListener(\"change\", onReducedChange);\n\n    const onVisibility = () => {\n      paused = document.hidden;\n      if (paused) stopLoops();\n      else startLoops();\n    };\n    document.addEventListener(\"visibilitychange\", onVisibility);\n\n    let io: IntersectionObserver | undefined;\n    if (\"IntersectionObserver\" in window) {\n      io = new IntersectionObserver((entries) => {\n        const entry = entries[0];\n        if (!entry) return;\n        paused = !entry.isIntersecting || document.hidden;\n        if (paused) stopLoops();\n        else startLoops();\n      });\n      io.observe(root);\n    }\n\n    return () => {\n      stopLoops();\n      if (announceInterval) clearInterval(announceInterval);\n      ro.disconnect();\n      mo.disconnect();\n      colorScheme.removeEventListener(\"change\", onThemeChange);\n      reducedMq.removeEventListener(\"change\", onReducedChange);\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n      io?.disconnect();\n      root.removeEventListener(\"pointermove\", onMove);\n      root.removeEventListener(\"pointerdown\", onMove);\n      root.removeEventListener(\"pointerleave\", onLeave);\n      root.removeEventListener(\"keydown\", onKey);\n      root.removeEventListener(\"blur\", onLeave);\n    };\n  }, []);\n\n  return (\n    <div className={`w-full select-none ${className}`}>\n      <div className=\"mb-2 flex items-end justify-between gap-4 border-b border-border pb-2\">\n        <div className=\"flex items-baseline gap-2\">\n          <span\n            aria-hidden\n            className=\"motion-safe:animate-pulse inline-block h-1.5 w-1.5 rounded-full bg-foreground\"\n          />\n          <span className=\"font-mono text-[11px] tracking-widest text-ns-muted\">\n            {label.toUpperCase()}\n          </span>\n        </div>\n        <div className=\"flex items-baseline gap-3 font-mono\">\n          <span\n            ref={readoutRef}\n            className=\"text-2xl font-semibold tabular-nums text-foreground\"\n          >\n            {formatValue ? formatValue(value) : `${Math.round(value)}${unit}`}\n          </span>\n          <span ref={trendRef} className=\"text-[11px] tabular-nums text-ns-muted\" />\n          <span\n            ref={rangeRef}\n            className=\"hidden text-[11px] tabular-nums text-ns-muted sm:inline\"\n          />\n        </div>\n      </div>\n      <div\n        ref={rootRef}\n        role=\"img\"\n        aria-label={`${label} chart recorder strip`}\n        tabIndex={0}\n        style={{ touchAction: \"pan-y\", height: \"calc(100% - 2.75rem)\" }}\n        className=\"relative w-full cursor-crosshair overflow-hidden rounded-md border border-border transition-colors duration-200 hover:border-foreground/25 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent\"\n      >\n        <canvas ref={canvasRef} aria-hidden className=\"absolute inset-0 h-full w-full\" />\n        <div\n          ref={armRef}\n          aria-hidden\n          className=\"pointer-events-none absolute h-px origin-left bg-foreground/60\"\n          style={{ left: 0, top: 0, width: 0 }}\n        />\n        <div\n          ref={carriageRef}\n          aria-hidden\n          className=\"pointer-events-none absolute h-2.5 w-2.5 rounded-full border-2 border-background bg-foreground\"\n          style={{ left: 0, top: 0 }}\n        />\n        <div\n          ref={cursorLineRef}\n          aria-hidden\n          className=\"pointer-events-none absolute inset-y-0 w-px bg-ns-accent opacity-0\"\n          style={{ left: 0 }}\n        />\n        <div\n          ref={cursorLabelRef}\n          aria-hidden\n          className=\"pointer-events-none absolute top-1 whitespace-nowrap rounded-sm border border-ns-accent/40 bg-background px-1.5 py-0.5 font-mono text-[10px] tabular-nums text-ns-accent opacity-0\"\n          style={{ left: 0 }}\n        />\n      </div>\n      <span ref={statusRef} role=\"status\" aria-live=\"polite\" className=\"sr-only\" />\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/hero-chart-recorder.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "loud",
    "tags": [
      "canvas",
      "data-viz",
      "chart-recorder",
      "live",
      "physics",
      "spring",
      "mono",
      "aria-live",
      "hero",
      "status-page"
    ],
    "instruction": "`<PenLag value unit label min max speed rangeMs formatValue className />` renders a wide chart-recorder strip: ruled horizontal lines (fixed, translation-invariant) and scrolling vertical time ticks (5s apart by default) drawn on a DPR-aware Canvas 2D, with a live ink trace that is the component's real subject. `value` is the consumer-supplied true telemetry reading, updated as often as the consumer likes; the pen never jumps to it. Every animation frame the pen's drawn position integrates one step of an underdamped spring toward `value` — stiffness 120, damping 8 (zeta ≈ 0.365, hardcoded, not props: this specific mechanical character is the component's identity) — so a step change overshoots past the target, rings through a couple of visibly decaying oscillations, and settles, exactly like a real needle recorder with real inertia. Each frame's pen position is appended once to a plain trace buffer (`{t, v}[]`, capped to a little over the `rangeMs` window, default 5 minutes) and NEVER recomputed afterward — the canvas redraws the buffer every frame purely by re-deriving each point's screen x from its age (`x = writeX - age/1000 * speed`), so the ink itself, including every quiver a spike ever produced, is permanent; only its position on screen slides left as the paper (implicitly) feeds under a fixed writing point at the strip's right margin. The pen arm and needle carriage are NOT canvas pixels — they're two absolutely-positioned DOM divs pinned to that fixed writing point (a pivoting arm whose length/angle are recomputed from trigonometry each frame, and a small carriage dot riding a vertical rail) so the moving mechanical parts stay crisp at any zoom while the historical trace stays raster. All draw colors (`--foreground` for ink, `--border` for rules/carriage-margin, `--ns-muted` for time ticks) are read via `getComputedStyle` at mount and re-derived on both a `MutationObserver` watching `documentElement`'s class/data-theme attributes AND a `prefers-color-scheme` `matchMedia` change listener, so the strip repaints correctly however the theme actually flips. `--ns-accent` appears exactly once: the vertical hairline and time/value label of the hovered or arrow-key-scrubbed timestamp cursor, never as decoration. ACCESSIBILITY: the canvas itself is `aria-hidden`; the strip wrapper carries `role=\"img\"` with an `aria-label` that stays a stable description at rest and switches to the live scrub reading (`\"-8s: 242ms\"`) while a cursor is active, plus a separate visually-hidden `role=\"status\" aria-live=\"polite\"` region that emits a threshold-debounced ambient summary (`\"Response time, currently 220ms, rising, 5-minute range 180–460ms\"`) only when the rounded value or trend word actually changes and the user isn't mid-scrub, so the region never spams. A real, always-visible Geist Mono readout (current value, trend word, min–max over the window) sits above the strip in plain DOM text — the number is never canvas-only. The strip is `tabIndex=0`; hovering OR focusing it with Left/Right arrows moves a fixed-screen-position probe that reads back whatever sample is currently passing beneath that column (the same DOM cursor serves both input modes) and announces its age and value. REDUCED MOTION: the continuous spring/scroll rAF loop is replaced by a throttled ~2Hz timer that snaps the pen directly to the true value (no overshoot — the spring's decorative ring is exactly the kind of motion `prefers-reduced-motion` users are opting out of) and does one full static redraw per tick instead of animating between them — live and legible, never smoothly sliding. The rAF loop also pauses on `document.hidden` and via an `IntersectionObserver` while the strip is scrolled offscreen, and every observer/listener/timer is torn down on unmount. Zero dependencies, DOM+Canvas 2D only, no WebGL."
  },
  "type": "registry:ui"
}