{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "helicorder-line-wrap",
  "title": "Helicorder Line Wrap",
  "description": "An ambient status widget shaped like a seismograph helicorder drum: a pen traces one line at a time and steps down to a fresh line the instant it runs off the right margin, so the record grows as a stack of offset traces rather than one continuous scroll.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/helicorder-line-wrap/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// HelicorderLineWrap — a seismograph helicorder drum record: a pen traces one\n// horizontal line at a time; when it runs off the right margin it steps down\n// to the row below and starts again at the left, so the record grows as a\n// stack of offset lines, oldest at top, rather than one continuous scroll.\n//\n// 8 fixed row slots, each its own <canvas> (a completed row is a static\n// bitmap — it is never redrawn once finished, only the live row is painted\n// incrementally). Once all 8 fill, a further wrap SHIFTS the whole stack up\n// by one row (canvas-to-canvas drawImage copies, oldest row's content\n// discarded) rather than looping/fading the whole card, so motion in the\n// tower stays continuous forever instead of resetting.\n//\n// The wrap itself — the ONE followable event per the round-9 legibility rule\n// — is never a teleport: the pen head fades out at the right margin over\n// 80ms, the stack shifts (or steps down) while it is invisible, and a fresh\n// pen head fades in at the left margin of the next row over 120ms. Idle\n// drawing pauses for that 200ms window so nothing competes with the step.\n// ---------------------------------------------------------------------------\n\nexport interface HelicorderLineWrapProps {\n  /** Seconds of app time per traced row. @default 12 */\n  lineSeconds?: number;\n  /** Number of stacked rows in the drum. @default 8 */\n  rows?: number;\n  /** Average seconds between quake spikes (randomized +-40%). @default 28 */\n  quakeIntervalSeconds?: number;\n  /** Freezes on the composed still frame without unmounting. */\n  paused?: boolean;\n  className?: string;\n  style?: React.CSSProperties;\n}\n\ntype Phase = \"drawing\" | \"fadeOut\" | \"fadeIn\";\n\nconst MARGIN_PX = 2; // px kept clear at each row's edges so the trace never touches the border\nconst WRAP_FADE_OUT_MS = 80;\nconst WRAP_FADE_IN_MS = 120;\nconst IDLE_AMP_PX = 2; // baseline microseism, +-2px\nconst QUAKE_AMP_MIN = 15;\nconst QUAKE_AMP_MAX = 20;\nconst QUAKE_RISE_MS = 200;\nconst QUAKE_DECAY_MS = 2500;\n\n// The reduced-motion / paused still: 4 of 8 rows completed, the 5th traced\n// 60% across, one quake bump baked into an earlier completed row — a clearly\n// mid-instrument, structured frame.\nconst STATIC_ROW = 4;\nconst STATIC_PROGRESS = 0.6;\n\n// Smooth, seeded pseudo-noise (sum of two incommensurate sines) so both the\n// live trace and the deterministic static frame can evaluate \"the same kind\n// of wiggle\" at any x without needing stored random samples.\nfunction microseism(x: number): number {\n  return IDLE_AMP_PX * (0.6 * Math.sin(x * 0.041 + 1.7) + 0.4 * Math.sin(x * 0.095 + 0.3));\n}\n\nfunction quakeEnvelope(elapsedMs: number): number {\n  if (elapsedMs < 0) return 0;\n  if (elapsedMs < QUAKE_RISE_MS) return elapsedMs / QUAKE_RISE_MS;\n  const t = elapsedMs - QUAKE_RISE_MS;\n  return Math.exp(-t / (QUAKE_DECAY_MS / 3));\n}\n\nexport function HelicorderLineWrap({\n  lineSeconds = 12,\n  rows = 8,\n  quakeIntervalSeconds = 28,\n  paused = false,\n  className = \"\",\n  style,\n}: HelicorderLineWrapProps) {\n  const wrapRef = useRef<HTMLDivElement | null>(null);\n  const rowRefs = useRef<(HTMLCanvasElement | null)[]>([]);\n  const headRef = useRef<HTMLDivElement | null>(null);\n  const uid = useId();\n  const pausedRef = useRef(paused);\n  pausedRef.current = paused;\n\n  useEffect(() => {\n    const wrap = wrapRef.current;\n    if (!wrap) return;\n    const canvases = rowRefs.current.slice(0, rows);\n    if (canvases.some((c) => !c)) return;\n    const head = headRef.current;\n\n    let disposed = false;\n    let raf = 0;\n    let running = false;\n    let staticMode = false;\n\n    let rowW = 0;\n    let rowH = 0;\n    let dpr = 1;\n\n    let fg = \"#171717\";\n    const readColors = () => {\n      const cs = getComputedStyle(document.documentElement);\n      fg = cs.getPropertyValue(\"--foreground\").trim() || \"#171717\";\n    };\n    readColors();\n\n    // ---- per-row backing store ---------------------------------------------\n    const ctxs: (CanvasRenderingContext2D | null)[] = canvases.map((c) =>\n      c!.getContext(\"2d\", { alpha: true })\n    );\n\n    const sizeCanvas = (c: HTMLCanvasElement, ctx: CanvasRenderingContext2D | null) => {\n      const pw = Math.max(2, Math.round(rowW * dpr));\n      const ph = Math.max(2, Math.round(rowH * dpr));\n      if (c.width !== pw || c.height !== ph) {\n        c.width = pw;\n        c.height = ph;\n      }\n      c.style.width = `${rowW}px`;\n      c.style.height = `${rowH}px`;\n      if (ctx) {\n        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      }\n    };\n\n    // ---- live-row state ------------------------------------------------------\n    let liveRow = 0; // index into canvases, current bottom of the growing tower\n    let stackFull = false;\n    let x = 0; // css px across the live row, 0..rowW\n    let lastX = 0;\n    let lastY = 0;\n    let lineMs = lineSeconds * 1000;\n    let speedPxPerMs = 0;\n\n    let simMs = 0;\n    let nextQuakeAtMs = quakeIntervalSeconds * 1000 * (0.6 + Math.random() * 0.8);\n    let quakeStartMs = -1e9;\n    let quakeAmp = 0;\n    let quakeSign = 1;\n\n    let phase: Phase = \"drawing\";\n    let phaseStartMs = 0;\n\n    const resetRow = () => {\n      x = MARGIN_PX;\n      lastX = MARGIN_PX;\n      lastY = rowH / 2;\n    };\n\n    const clearRowCanvas = (i: number) => {\n      const c = canvases[i]!;\n      const ctx = ctxs[i]!;\n      ctx.setTransform(1, 0, 0, 1, 0, 0);\n      ctx.clearRect(0, 0, c.width, c.height);\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n    };\n\n    const shiftStackUp = () => {\n      // canvas-to-canvas copy: row i takes row i+1's finished bitmap, row\n      // `rows-1` (the one about to become live again) is cleared. This is\n      // what makes the oldest trace scroll off the top instead of the whole\n      // card looping and restarting.\n      for (let i = 0; i < rows - 1; i++) {\n        const dst = canvases[i]!;\n        const dctx = ctxs[i]!;\n        const src = canvases[i + 1]!;\n        dctx.setTransform(1, 0, 0, 1, 0, 0);\n        dctx.clearRect(0, 0, dst.width, dst.height);\n        dctx.drawImage(src, 0, 0);\n        dctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      }\n      clearRowCanvas(rows - 1);\n    };\n\n    const drawSegment = (row: number, x0: number, y0: number, x1: number, y1: number) => {\n      const ctx = ctxs[row];\n      if (!ctx) return;\n      ctx.strokeStyle = fg;\n      ctx.lineWidth = 1.4;\n      ctx.lineCap = \"round\";\n      ctx.beginPath();\n      ctx.moveTo(x0, y0);\n      ctx.lineTo(x1, y1);\n      ctx.stroke();\n    };\n\n    const traceY = (progressX: number, nowMs: number) => {\n      let y = rowH / 2 + microseism(progressX);\n      const env = quakeEnvelope(nowMs - quakeStartMs);\n      if (env > 0.001) y += quakeSign * quakeAmp * env;\n      return y;\n    };\n\n    const placeHead = (rowIndex: number, px: number, py: number, opacity: number) => {\n      if (!head) return;\n      head.style.transform = `translate(${px - 2}px, ${rowIndex * rowH + py - 2}px)`;\n      head.style.opacity = String(opacity);\n    };\n\n    // ---- deterministic static frame -----------------------------------------\n    const drawStaticFrame = () => {\n      for (let i = 0; i < rows; i++) clearRowCanvas(i);\n      if (head) head.style.opacity = \"0\";\n      for (let r = 0; r < rows; r++) {\n        if (r > STATIC_ROW) continue;\n        const fullWidth = r < STATIC_ROW;\n        const targetX = fullWidth ? rowW - MARGIN_PX : MARGIN_PX + (rowW - 2 * MARGIN_PX) * STATIC_PROGRESS;\n        let px = MARGIN_PX;\n        let py = rowH / 2;\n        const step = 3;\n        for (let sx = MARGIN_PX + step; sx <= targetX; sx += step) {\n          let sy = rowH / 2 + microseism(sx);\n          // bake one quake bump into row 1, mid-height, so the still isn't a\n          // flat instrument with nothing having ever happened\n          if (r === 1) {\n            const bumpCenter = rowW * 0.55;\n            const bumpEnv = quakeEnvelope(QUAKE_RISE_MS + (sx - bumpCenter) * 22);\n            if (sx >= bumpCenter) sy += 18 * bumpEnv;\n          }\n          drawSegment(r, px, py, sx, sy);\n          px = sx;\n          py = sy;\n        }\n      }\n    };\n\n    // ---- main loop ------------------------------------------------------------\n    let lastMs = performance.now();\n\n    const stepDrawing = (dt: number, nowMs: number) => {\n      if (nextQuakeAtMs <= simMs) {\n        quakeStartMs = simMs;\n        quakeAmp = QUAKE_AMP_MIN + Math.random() * (QUAKE_AMP_MAX - QUAKE_AMP_MIN);\n        quakeSign = Math.random() < 0.5 ? -1 : 1;\n        nextQuakeAtMs = simMs + quakeIntervalSeconds * 1000 * (0.64 + Math.random() * 1.43);\n      }\n\n      x += speedPxPerMs * dt;\n      const clampedX = Math.min(x, rowW - MARGIN_PX);\n      const y = traceY(clampedX, simMs);\n      drawSegment(liveRow, lastX, lastY, clampedX, y);\n      lastX = clampedX;\n      lastY = y;\n      placeHead(liveRow, clampedX, y, 1);\n\n      if (x >= rowW - MARGIN_PX) {\n        phase = \"fadeOut\";\n        phaseStartMs = nowMs;\n      }\n    };\n\n    const stepWrap = (nowMs: number) => {\n      const elapsed = nowMs - phaseStartMs;\n      if (phase === \"fadeOut\") {\n        const t = Math.min(1, elapsed / WRAP_FADE_OUT_MS);\n        placeHead(liveRow, lastX, lastY, 1 - t);\n        if (t >= 1) {\n          // the step itself happens while the head is invisible\n          if (stackFull) {\n            shiftStackUp();\n            liveRow = rows - 1;\n          } else if (liveRow < rows - 1) {\n            liveRow += 1;\n            if (liveRow === rows - 1) stackFull = true;\n          } else {\n            stackFull = true;\n            shiftStackUp();\n          }\n          resetRow();\n          phase = \"fadeIn\";\n          phaseStartMs = nowMs;\n          placeHead(liveRow, lastX, lastY, 0);\n        }\n      } else if (phase === \"fadeIn\") {\n        const t = Math.min(1, elapsed / WRAP_FADE_IN_MS);\n        placeHead(liveRow, lastX, lastY, t);\n        if (t >= 1) phase = \"drawing\";\n      }\n    };\n\n    const loop = (nowMs: number) => {\n      const rawMs = Math.min(50, nowMs - lastMs);\n      lastMs = nowMs;\n      simMs += rawMs;\n      if (phase === \"drawing\") stepDrawing(rawMs, nowMs);\n      else stepWrap(nowMs);\n      raf = requestAnimationFrame(loop);\n    };\n\n    const wake = () => {\n      if (running || disposed) return;\n      running = true;\n      lastMs = performance.now();\n      raf = requestAnimationFrame(loop);\n    };\n    const sleep = () => {\n      cancelAnimationFrame(raf);\n      running = false;\n    };\n\n    const resetAll = () => {\n      for (let i = 0; i < rows; i++) clearRowCanvas(i);\n      liveRow = 0;\n      stackFull = false;\n      resetRow();\n      phase = \"drawing\";\n      if (head) head.style.opacity = \"0\";\n    };\n\n    const applySize = () => {\n      const rect = wrap.getBoundingClientRect();\n      if (rect.width < 2 || rect.height < 2) return;\n      const changed = Math.abs(rect.width - rowW) > 0.5 || Math.abs(rect.height / rows - rowH) > 0.5;\n      rowW = rect.width;\n      rowH = rect.height / rows;\n      dpr = Math.min(window.devicePixelRatio || 1, 2);\n      lineMs = Math.max(1000, lineSeconds * 1000);\n      speedPxPerMs = rowW / lineMs;\n      for (let i = 0; i < rows; i++) sizeCanvas(canvases[i]!, ctxs[i]);\n      if (changed) {\n        if (staticMode) drawStaticFrame();\n        else resetAll();\n      }\n    };\n\n    const ro = new ResizeObserver(applySize);\n    ro.observe(wrap);\n    applySize();\n\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    let reduced = mq.matches;\n    const applyMode = () => {\n      if (reduced || pausedRef.current) {\n        staticMode = true;\n        sleep();\n        drawStaticFrame();\n      } else {\n        if (staticMode) resetAll();\n        staticMode = false;\n        wake();\n      }\n    };\n    const onMq = () => {\n      reduced = mq.matches;\n      applyMode();\n    };\n    mq.addEventListener(\"change\", onMq);\n\n    let onScreen = true;\n    const io = new IntersectionObserver(\n      (entries) => {\n        onScreen = entries.some((en) => en.isIntersecting);\n        if (!onScreen) sleep();\n        else if (!staticMode && !document.hidden) wake();\n      },\n      { threshold: 0 }\n    );\n    io.observe(wrap);\n\n    const onVis = () => {\n      if (document.hidden) sleep();\n      else if (!staticMode && onScreen) wake();\n    };\n    document.addEventListener(\"visibilitychange\", onVis);\n    applyMode();\n\n    let lastPolledPaused = pausedRef.current;\n    let poll = 0;\n    const tick = () => {\n      if (pausedRef.current !== lastPolledPaused) {\n        lastPolledPaused = pausedRef.current;\n        applyMode();\n      }\n      poll = window.setTimeout(tick, 140);\n    };\n    tick();\n\n    const themeObserver = new MutationObserver(() => {\n      readColors();\n      if (staticMode) drawStaticFrame();\n    });\n    themeObserver.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n\n    return () => {\n      disposed = true;\n      ro.disconnect();\n      io.disconnect();\n      mq.removeEventListener(\"change\", onMq);\n      document.removeEventListener(\"visibilitychange\", onVis);\n      themeObserver.disconnect();\n      window.clearTimeout(poll);\n      sleep();\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [lineSeconds, rows, quakeIntervalSeconds]);\n\n  return (\n    <div\n      ref={wrapRef}\n      data-helicorder-line-wrap={uid}\n      role=\"img\"\n      aria-label=\"Seismic activity monitor, live helicorder trace\"\n      className={`relative flex h-full w-full flex-col overflow-hidden bg-background ${className}`}\n      style={style}\n    >\n      {Array.from({ length: rows }).map((_, i) => (\n        <div key={i} className={`relative flex-1 ${i > 0 ? \"border-t border-border\" : \"\"}`}>\n          <canvas\n            ref={(el) => {\n              rowRefs.current[i] = el;\n            }}\n            aria-hidden=\"true\"\n            className=\"absolute inset-0 block h-full w-full\"\n          />\n        </div>\n      ))}\n      {/* the pen head: a single overlay dot, its position and opacity\n          written directly every frame (never React state) so the 80ms/120ms\n          wrap fade reads as one continuous instrument, not a re-render */}\n      <div\n        ref={headRef}\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute left-0 top-0 h-1 w-1 rounded-full bg-foreground opacity-0\"\n      />\n    </div>\n  );\n}\n\nHelicorderLineWrap.displayName = \"HelicorderLineWrap\";\n",
      "type": "registry:ui",
      "target": "components/ui/helicorder-line-wrap.tsx"
    }
  ],
  "meta": {
    "collection": "core",
    "tags": [
      "loader",
      "status",
      "canvas",
      "monitoring",
      "trace",
      "raf",
      "ambient"
    ],
    "instruction": "Build <HelicorderLineWrap lineSeconds? rows? quakeIntervalSeconds? paused? className?> as a card-scale ambient status widget reproducing a seismograph helicorder drum record. LAYOUT: `rows` (default 8) stacked flex-1 row divs inside one flex-col wrapper, each row a full-width/full-height <canvas> (its own backing store, DPR-capped at 2), separated by a `border-t border-border` hairline between rows (no border above the first). A completed row's canvas is a static bitmap that is NEVER redrawn once finished — only the current LIVE row is painted, incrementally, one short line segment per frame from the last plotted point to the new one. LIVE TRACE: the pen advances left to right across the live row's canvas at `rowWidth / (lineSeconds * 1000)` px/ms (lineSeconds default 12, so a real helicorder's 15-real-minute line is compressed to 12 app-seconds, a documented decouple — 15 real minutes would be far too slow to read as alive). Its vertical offset is `rowHeight/2 + microseism(x)`, where microseism is a smooth deterministic pseudo-noise (sum of two incommensurate sines) bounded to +-2px baseline amplitude, PLUS a quake envelope when one is live: quakes fire on an average `quakeIntervalSeconds` (default 28s, randomized 0.64x-2.07x per interval so it reads as irregular, not metronomic), amplitude 15-20px randomized, rising linearly over 200ms then decaying exponentially with a ~2.5s time constant, sign randomized per quake — clearly distinguishable from the idle baseline by height alone, never by color. WRAP: when the live row's x reaches the right margin, drawing PAUSES for an explicit 200ms two-phase transition — the pen-head overlay (a single absolutely-positioned 4px dot, its transform/opacity written directly every frame, never through React state) fades out over 80ms at its final right-margin position, then, invisible, the row step happens: while fewer than `rows` rows have ever been completed, the next row down simply becomes the new live row (nothing shifts); once all `rows` rows are full, every subsequent wrap instead SHIFTS the whole stack up by one row via canvas-to-canvas `drawImage` copies (row i takes row i+1's finished bitmap, the last slot is cleared and becomes live) so the oldest trace scrolls off the top and the tower keeps growing forever rather than the whole card fading and restarting. The pen head then fades back in over 120ms at the left margin of the new live row before drawing resumes — a visible departure and arrival, never a teleport, which is what makes the wrap itself (not a generic scrolling ticker) the followable event. TOKENS: canvas stroke color is `--foreground` read via `getComputedStyle(document.documentElement)` at mount and re-read on a documentElement class MutationObserver (re-painting only the static/reduced frame — the live trace's ink for already-drawn pixels does not retroactively recolor, matching how a real pen trace doesn't change color after the fact); row separators are the `--border` token via the Tailwind border classes, never a fill. HOST: one ResizeObserver on the wrapper (not window.resize) recomputes row width/height and the px/ms sweep speed from it; on any size change the whole tower resets and restarts from row 0 (a full resize invalidates all baked row bitmaps, so restarting cleanly beats trying to rescale finished traces). IntersectionObserver (threshold 0) and visibilitychange both pause the single rAF loop; prefers-reduced-motion and `paused` both freeze on a deterministically-drawn static frame (STATIC_ROW=4, STATIC_PROGRESS=0.6: rows 0-3 complete, row 4 traced 60% of the way across, one baked quake bump on row 1) rather than a blank or fully-filled stack, with the pen head hidden. No interaction is required (this is an ambient status widget, not a control) so nothing here uses `--ns-accent` — it is reserved for interactive chrome this component doesn't have. Pure DOM + 2D canvas, zero dependencies."
  },
  "type": "registry:ui"
}