{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "telegraph",
  "type": "registry:ui",
  "title": "Telegraph",
  "description": "A self-contained terminal that types a scripted session on its own — macOS window chrome, command lines typing behind a cool prompt sigil with a blinking block caret, output blocks fading in whole, auto-sequenced on scroll-into-view. Real selectable text, single active-line rAF that pauses offscreen, fully static under reduced motion.",
  "dependencies": [],
  "registryDependencies": [
    "https://crucibleui.com/r/use-reduced-motion.json",
    "https://crucibleui.com/r/use-visibility-pause.json"
  ],
  "files": [
    {
      "path": "registry/default/utility/telegraph/telegraph.tsx",
      "type": "registry:ui",
      "target": "components/crucible/telegraph.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { useVisibilityPause } from \"@/hooks/use-visibility-pause\";\n\n/* ------------------------------------------------------------------ types */\n\n/**\n * Coloring role for a run of terminal text. Everything defaults to a warm\n * off-white; the semantic tints are deliberately quiet so a session reads as\n * one calm block, not a rainbow.\n */\nexport type TelegraphTone =\n  | \"default\"\n  | \"muted\"\n  | \"accent\"\n  | \"success\"\n  | \"info\"\n  | \"warn\"\n  | \"error\"\n  | \"string\";\n\nexport interface TelegraphSegment {\n  /** Literal text of this run. */\n  text: string;\n  /** Coloring role. @default \"default\" */\n  tone?: TelegraphTone;\n}\n\nexport interface TelegraphLine {\n  /**\n   * `\"command\"` lines type out character-by-character behind a prompt sigil;\n   * `\"output\"` lines fade in whole, like a program's response.\n   */\n  kind: \"command\" | \"output\";\n  /**\n   * Prompt sigil shown before a command line (overrides the component\n   * `prompt`). Ignored for output lines.\n   */\n  prompt?: string;\n  /**\n   * The line body. A plain string is treated as one default-toned run; pass an\n   * array to color individual segments. An empty command line is a resting\n   * prompt (the caret parks there when the session finishes).\n   */\n  content: string | TelegraphSegment[];\n}\n\nexport interface TelegraphProps\n  extends Omit<React.ComponentPropsWithoutRef<\"figure\">, \"content\" | \"children\"> {\n  /** The scripted session, top to bottom. @default a short install/deploy session */\n  lines?: TelegraphLine[];\n  /** Window title shown in the title bar. @default \"zsh — ~/my-app\" */\n  title?: string;\n  /** Default prompt sigil for command lines. @default \"$\" */\n  prompt?: string;\n  /**\n   * The single cool accent — used for the prompt sigil, the caret glow, and\n   * the `accent`/`string` tones. Any CSS color. @default sky \"#7dd3fc\"\n   */\n  accent?: string;\n  /** Typing-rate multiplier (and reveal cadence). @default 1 */\n  speed?: number;\n  /** Seconds to wait after scrolling into view before typing begins. @default 0.3 */\n  startDelay?: number;\n  /** Replay the session on a loop instead of resting at the end. @default false */\n  loop?: boolean;\n  /** Freeze at the fully rendered final transcript, no typing. @default false */\n  paused?: boolean;\n}\n\n/* --------------------------------------------------------------- defaults */\n\n/** Sky-300 — cool, calm, elegant on charcoal. Not the green every clone uses. */\nconst DEFAULT_ACCENT = \"#7dd3fc\";\nconst DEFAULT_TITLE = \"zsh — ~/my-app\";\n\nconst DEFAULT_LINES: TelegraphLine[] = [\n  { kind: \"command\", content: [{ text: \"npm create app@latest my-app\" }] },\n  {\n    kind: \"output\",\n    content: [\n      { text: \"✓\", tone: \"success\" },\n      { text: \" scaffolded \" },\n      { text: \"my-app\", tone: \"string\" },\n      { text: \" (react + vite)\", tone: \"muted\" },\n    ],\n  },\n  { kind: \"command\", content: [{ text: \"cd my-app && npm install\" }] },\n  {\n    kind: \"output\",\n    content: [{ text: \"added 214 packages in 3.1s\", tone: \"muted\" }],\n  },\n  { kind: \"command\", content: [{ text: \"npm run deploy\" }] },\n  {\n    kind: \"output\",\n    content: [\n      { text: \"▸\", tone: \"info\" },\n      { text: \" building production bundle…\", tone: \"muted\" },\n    ],\n  },\n  {\n    kind: \"output\",\n    content: [\n      { text: \"✓\", tone: \"success\" },\n      { text: \" build complete \" },\n      { text: \"— 1.24 MB gzipped\", tone: \"muted\" },\n    ],\n  },\n  {\n    kind: \"output\",\n    content: [\n      { text: \"✓\", tone: \"success\" },\n      { text: \" live at \" },\n      { text: \"https://my-app.example.com\", tone: \"string\" },\n    ],\n  },\n  { kind: \"command\", content: \"\" },\n];\n\n/* --------------------------------------------------------------- timing */\n\nconst BASE_CPS = 42; // characters per second at speed = 1\nconst COMMAND_HOLD = 0.42; // pause after a command finishes typing, before output\nconst OUTPUT_STEP = 0.16; // gap between successive line reveals\nconst END_HOLD = 1.8; // rest at the end before a loop restarts\n\n/* --------------------------------------------------------------- helpers */\n\nfunction toneColor(tone: TelegraphTone = \"default\"): string {\n  switch (tone) {\n    case \"muted\":\n      return \"rgba(245,243,237,0.42)\";\n    case \"accent\":\n      return \"var(--tg-accent)\";\n    case \"success\":\n      return \"#7ee0b0\";\n    case \"info\":\n      return \"#9ec5fb\";\n    case \"warn\":\n      return \"#f2c879\";\n    case \"error\":\n      return \"#f0a1a1\";\n    case \"string\":\n      return \"color-mix(in oklab, var(--tg-accent) 58%, white)\";\n    default:\n      return \"rgba(245,243,237,0.92)\";\n  }\n}\n\nfunction normalize(content: string | TelegraphSegment[]): TelegraphSegment[] {\n  if (typeof content === \"string\") return content ? [{ text: content }] : [];\n  return content;\n}\n\nfunction lineLength(segments: TelegraphSegment[]): number {\n  return segments.reduce((n, s) => n + s.text.length, 0);\n}\n\n/** First `count` characters across the segments, preserving each segment's tone. */\nfunction sliceSegments(segments: TelegraphSegment[], count: number): TelegraphSegment[] {\n  const out: TelegraphSegment[] = [];\n  let remaining = count;\n  for (const seg of segments) {\n    if (remaining <= 0) break;\n    const take = Math.min(seg.text.length, remaining);\n    out.push(take === seg.text.length ? seg : { ...seg, text: seg.text.slice(0, take) });\n    remaining -= take;\n  }\n  return out;\n}\n\ninterface Scheduled {\n  segments: TelegraphSegment[];\n  kind: \"command\" | \"output\";\n  prompt: string;\n  length: number;\n  /** When this line begins, in seconds from session start. */\n  start: number;\n  /** Seconds to type this line's characters (0 for output). */\n  typeDuration: number;\n}\n\n/* ------------------------------------------------------------- component */\n\n/**\n * Telegraph — a self-contained terminal that types a scripted session on its\n * own. macOS-style window chrome frames a charcoal screen; command lines type\n * out character-by-character behind a cool prompt sigil with a blinking block\n * caret, and output lines fade in whole, auto-sequenced so each waits for the\n * one before it. Playback starts when the terminal scrolls into view and runs\n * a single rAF that only ticks the active line — it pauses offscreen and on\n * hidden tabs. Every glyph is real, selectable DOM text; a screen reader hears\n * the finished transcript once. Under reduced motion (or `paused`) the whole\n * session renders statically with no typing.\n */\nexport function Telegraph({\n  lines = DEFAULT_LINES,\n  title = DEFAULT_TITLE,\n  prompt = \"$\",\n  accent = DEFAULT_ACCENT,\n  speed = 1,\n  startDelay = 0.3,\n  loop = false,\n  paused = false,\n  className,\n  style,\n  ...props\n}: TelegraphProps) {\n  const reducedMotion = useReducedMotion();\n  const still = reducedMotion || paused;\n\n  // Build the deterministic timeline once per lines/speed/prompt change.\n  const { schedule, total } = React.useMemo(() => {\n    const cps = BASE_CPS * Math.max(speed, 0.05);\n    let cursor = 0;\n    const out: Scheduled[] = lines.map((line) => {\n      const segments = normalize(line.content);\n      const length = lineLength(segments);\n      const start = cursor;\n      let typeDuration = 0;\n      if (line.kind === \"command\") {\n        typeDuration = length / cps;\n        cursor = start + typeDuration + COMMAND_HOLD / Math.max(speed, 0.05);\n      } else {\n        cursor = start + OUTPUT_STEP / Math.max(speed, 0.05);\n      }\n      return {\n        segments,\n        kind: line.kind,\n        prompt: line.prompt ?? prompt,\n        length,\n        start,\n        typeDuration,\n      };\n    });\n    return { schedule: out, total: cursor };\n  }, [lines, speed, prompt]);\n\n  const count = schedule.length;\n\n  // Current frame: which line is active, how many of its chars are shown,\n  // and whether the session has completed. Kept minimal so the rAF only\n  // re-renders when the visible output actually changes.\n  const [frame, setFrame] = React.useState({ visible: 0, chars: 0, done: false });\n  const [finished, setFinished] = React.useState(false);\n  const sigRef = React.useRef(\"\");\n\n  const tick = React.useCallback(\n    (elapsed: number) => {\n      let t = elapsed - startDelay;\n      let done = false;\n      if (loop) {\n        t = ((t % (total + END_HOLD)) + total + END_HOLD) % (total + END_HOLD);\n      }\n      if (t < 0) t = 0;\n      if (!loop && t >= total) done = true;\n\n      // Last line whose start has passed.\n      let visible = 0;\n      for (let i = 0; i < count; i++) {\n        if (schedule[i].start <= t) visible = i;\n        else break;\n      }\n\n      const active = schedule[visible];\n      let chars = active.length;\n      if (active.kind === \"command\" && t < active.start + active.typeDuration) {\n        chars = Math.max(0, Math.floor((t - active.start) * BASE_CPS * Math.max(speed, 0.05)));\n      }\n\n      const sig = `${visible}:${chars}:${done ? 1 : 0}`;\n      if (sig !== sigRef.current) {\n        sigRef.current = sig;\n        setFrame({ visible, chars, done });\n      }\n      if (done && !loop && !finished) setFinished(true);\n    },\n    [schedule, total, count, startDelay, speed, loop, finished]\n  );\n\n  // Single rAF, view-gated + tab-gated by the hook. It stops entirely once the\n  // (non-looping) session has finished — the resting caret blink is pure CSS.\n  const containerRef = useVisibilityPause<HTMLElement>(tick, {\n    paused: still || (finished && !loop),\n  });\n\n  // Reset when the source or speed changes so a new session replays cleanly.\n  React.useEffect(() => {\n    sigRef.current = \"\";\n    setFrame({ visible: 0, chars: 0, done: false });\n    setFinished(false);\n  }, [schedule]);\n\n  const transcript = React.useMemo(\n    () =>\n      schedule\n        .map((l) => {\n          const body = l.segments.map((s) => s.text).join(\"\");\n          return l.kind === \"command\" ? `${l.prompt} ${body}`.trimEnd() : body;\n        })\n        .join(\"\\n\"),\n    [schedule]\n  );\n\n  const restIndex = lastCommandIndex(schedule);\n\n  const renderLine = (\n    line: Scheduled,\n    segments: TelegraphSegment[],\n    caret: boolean,\n    fade: boolean,\n    key: number\n  ) => (\n    <div key={key} className={cn(\"whitespace-pre\", fade && !still && \"tg-in\")}>\n      {line.kind === \"command\" && (\n        <span className=\"tg-sigil\" aria-hidden>\n          {line.prompt}{\" \"}\n        </span>\n      )}\n      {segments.map((seg, i) => (\n        <span key={i} style={{ color: toneColor(seg.tone) }}>\n          {seg.text}\n        </span>\n      ))}\n      {caret && <span className=\"tg-caret\" aria-hidden />}\n    </div>\n  );\n\n  // The visible screen. Static (reduced motion / paused) renders the whole\n  // transcript with the caret parked on the final command line.\n  const body = still\n    ? schedule.map((line, i) => {\n        const lastCommand = line.kind === \"command\" && i === restIndex;\n        return renderLine(line, line.segments, lastCommand, false, i);\n      })\n    : schedule.slice(0, frame.visible + 1).map((line, i) => {\n        const isActive = i === frame.visible;\n        const fullyTyped = !isActive || line.kind === \"output\" || frame.chars >= line.length;\n        const shown =\n          isActive && line.kind === \"command\" && !fullyTyped\n            ? sliceSegments(line.segments, frame.chars)\n            : line.segments;\n        // Caret rides the active command line while typing/holding; when the\n        // session is done it parks on the final command line.\n        const caret =\n          line.kind === \"command\" && (isActive || (frame.done && i === restIndex));\n        const fade = line.kind === \"output\";\n        return renderLine(line, shown, caret, fade, i);\n      });\n\n  const lineBoxHeight = `${count * 1.55 + 1.5}rem`;\n\n  return (\n    <figure\n      ref={containerRef as React.RefObject<HTMLElement>}\n      data-crucible=\"telegraph\"\n      aria-label={`Terminal session: ${title}`}\n      className={cn(\n        \"not-prose w-full max-w-xl overflow-hidden rounded-xl border border-white/10\",\n        \"bg-neutral-900/85 shadow-[0_28px_70px_-30px_rgba(0,0,0,0.85)] backdrop-blur-sm\",\n        className\n      )}\n      style={{ [\"--tg-accent\" as string]: accent, ...style }}\n      {...props}\n    >\n      <style>{`\n        [data-crucible=\"telegraph\"] .tg-sigil {\n          color: var(--tg-accent);\n          text-shadow: 0 0 10px color-mix(in oklab, var(--tg-accent) 45%, transparent);\n        }\n        [data-crucible=\"telegraph\"] .tg-caret {\n          display: inline-block;\n          width: 0.6em;\n          height: 1.05em;\n          margin-left: 0.06em;\n          vertical-align: text-bottom;\n          background: rgba(245,243,237,0.9);\n          box-shadow: 0 0 8px 1px color-mix(in oklab, var(--tg-accent) 40%, transparent);\n          animation: tg-blink 1.05s steps(1) infinite;\n        }\n        [data-crucible=\"telegraph\"] .tg-in {\n          animation: tg-in 0.32s ease-out both;\n        }\n        @keyframes tg-blink { 0%,52% { opacity: 1 } 53%,100% { opacity: 0 } }\n        @keyframes tg-in { from { opacity: 0; transform: translateY(3px) } to { opacity: 1; transform: none } }\n        @media (prefers-reduced-motion: reduce) {\n          [data-crucible=\"telegraph\"] .tg-caret { animation: none; opacity: 0.5 }\n          [data-crucible=\"telegraph\"] .tg-in { animation: none }\n        }\n      `}</style>\n\n      {/* Title bar with macOS-style controls (neutral, brand-agnostic). */}\n      <figcaption className=\"flex items-center gap-2 border-b border-white/8 bg-white/2 px-3.5 py-2.5\">\n        <span aria-hidden className=\"flex gap-1.5\">\n          <span className=\"h-3 w-3 rounded-full bg-white/15\" />\n          <span className=\"h-3 w-3 rounded-full bg-white/15\" />\n          <span className=\"h-3 w-3 rounded-full bg-white/15\" />\n        </span>\n        <span className=\"mx-auto truncate px-2 font-mono text-xs text-white/40\">{title}</span>\n        <span aria-hidden className=\"h-3 w-3\" />\n      </figcaption>\n\n      {/* Screen-reader transcript — read once; the animated screen is decorative. */}\n      <span className=\"sr-only\">{transcript}</span>\n\n      <div\n        aria-hidden\n        className=\"overflow-x-auto px-4 py-3.5 font-mono text-[13px] leading-[1.55] sm:px-5 sm:py-4\"\n        style={{ minHeight: lineBoxHeight }}\n      >\n        {body}\n      </div>\n    </figure>\n  );\n}\n\n/** Index of the last command line (where the resting caret parks), or -1. */\nfunction lastCommandIndex(schedule: Scheduled[]): number {\n  for (let i = schedule.length - 1; i >= 0; i--) {\n    if (schedule[i].kind === \"command\") return i;\n  }\n  return -1;\n}\n"
    }
  ]
}