{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion"],"description":"A MacTerminal component for SmoothUI.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\n\nconst DEFAULT_PROMPT = \"~ %\";\nconst DEFAULT_TITLE = \"Terminal\";\nconst DEFAULT_TYPING_SPEED = 24;\nconst DEFAULT_MAX_HEIGHT = 360;\nconst TICK_MS = 40;\nconst LOOP_PAUSE_MS = 1500;\nconst OUTPUT_DEFAULT_DELAY_MS = 200;\nconst INTERSECTION_THRESHOLD = 0.35;\nconst KEY_ENTER = \"Enter\";\nconst KEY_SPACE = \" \";\nconst TRAFFIC_LIGHT_CLASS = \"size-3 rounded-full\";\n\nexport type TerminalLineType =\n  | \"command\"\n  | \"output\"\n  | \"error\"\n  | \"success\"\n  | \"comment\";\n\nexport interface TerminalLine {\n  delay?: number;\n  id: string;\n  text: string;\n  type: TerminalLineType;\n}\n\nexport type MacTerminalTheme = \"dark\" | \"light\" | \"auto\";\n\nexport interface MacTerminalProps {\n  autoPlay?: boolean;\n  className?: string;\n  lines: TerminalLine[];\n  loop?: boolean;\n  maxHeight?: number | string;\n  onClose?: () => void;\n  onComplete?: () => void;\n  onMinimize?: () => void;\n  prompt?: string;\n  showTrafficLights?: boolean;\n  theme?: MacTerminalTheme;\n  title?: string;\n  typingSpeed?: number;\n}\n\ntype MachinePhase = \"delay\" | \"typing\";\n\ninterface Machine {\n  finished: boolean;\n  lineIndex: number;\n  phase: MachinePhase;\n  phaseElapsed: number;\n}\n\ninterface TypingDraft {\n  chars: number;\n  line: TerminalLine;\n}\n\ninterface ThemeStyle {\n  body: string;\n  border: string;\n  caret: string;\n  chrome: string;\n  command: string;\n  comment: string;\n  error: string;\n  output: string;\n  prompt: string;\n  success: string;\n  title: string;\n}\n\nconst THEME_STYLES: Record<\"dark\" | \"light\", ThemeStyle> = {\n  dark: {\n    body: \"bg-zinc-950 text-zinc-100\",\n    border: \"border-white/10\",\n    caret: \"bg-emerald-400\",\n    chrome: \"bg-zinc-900\",\n    command: \"text-zinc-50\",\n    comment: \"text-zinc-500\",\n    error: \"text-red-400\",\n    output: \"text-zinc-300\",\n    prompt: \"text-emerald-400\",\n    success: \"text-emerald-400\",\n    title: \"text-zinc-400\",\n  },\n  light: {\n    body: \"bg-white text-zinc-800\",\n    border: \"border-zinc-200\",\n    caret: \"bg-indigo-500\",\n    chrome: \"bg-zinc-100\",\n    command: \"text-zinc-900\",\n    comment: \"text-zinc-400\",\n    error: \"text-red-600\",\n    output: \"text-zinc-600\",\n    prompt: \"text-indigo-600\",\n    success: \"text-emerald-600\",\n    title: \"text-zinc-500\",\n  },\n};\n\nconst createMachine = (): Machine => ({\n  finished: false,\n  lineIndex: 0,\n  phase: \"delay\",\n  phaseElapsed: 0,\n});\n\nconst getLineToneClass = (\n  type: TerminalLineType,\n  styles: ThemeStyle\n): string => {\n  switch (type) {\n    case \"error\":\n      return styles.error;\n    case \"success\":\n      return styles.success;\n    case \"comment\":\n      return styles.comment;\n    case \"command\":\n      return styles.command;\n    default:\n      return styles.output;\n  }\n};\n\nexport default function MacTerminal({\n  autoPlay = true,\n  className,\n  lines,\n  loop = false,\n  maxHeight = DEFAULT_MAX_HEIGHT,\n  onClose,\n  onComplete,\n  onMinimize,\n  prompt = DEFAULT_PROMPT,\n  showTrafficLights = true,\n  theme = \"dark\",\n  title = DEFAULT_TITLE,\n  typingSpeed = DEFAULT_TYPING_SPEED,\n}: MacTerminalProps) {\n  const shouldReduceMotion = useReducedMotion();\n  const containerRef = useRef<HTMLDivElement>(null);\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const machineRef = useRef<Machine>(createMachine());\n  const shouldPlayRef = useRef(false);\n  const linesRef = useRef(lines);\n  const loopRef = useRef(loop);\n  const onCompleteRef = useRef(onComplete);\n  const typingSpeedRef = useRef(\n    typingSpeed > 0 ? typingSpeed : DEFAULT_TYPING_SPEED\n  );\n  const calledOnCompleteRef = useRef(false);\n\n  linesRef.current = lines;\n  loopRef.current = loop;\n  onCompleteRef.current = onComplete;\n  typingSpeedRef.current = typingSpeed > 0 ? typingSpeed : DEFAULT_TYPING_SPEED;\n\n  const [completedLines, setCompletedLines] = useState<TerminalLine[]>([]);\n  const [typingDraft, setTypingDraft] = useState<TypingDraft | null>(null);\n  const [hasStarted, setHasStarted] = useState(autoPlay);\n  const [isInView, setIsInView] = useState(false);\n  const [isPageVisible, setIsPageVisible] = useState(true);\n  const [systemPrefersDark, setSystemPrefersDark] = useState(true);\n\n  const isInteractive = !(autoPlay || hasStarted || shouldReduceMotion);\n  const resolvedTheme: \"dark\" | \"light\" =\n    theme === \"auto\" ? (systemPrefersDark ? \"dark\" : \"light\") : theme;\n  const styles = THEME_STYLES[resolvedTheme];\n  const resolvedMaxHeight =\n    typeof maxHeight === \"number\" ? `${maxHeight}px` : maxHeight;\n  const hasLightHandlers = Boolean(onClose || onMinimize);\n\n  useEffect(() => {\n    const node = containerRef.current;\n    if (!node) {\n      return;\n    }\n\n    const observer = new IntersectionObserver(\n      ([entry]) => {\n        setIsInView(entry?.isIntersecting ?? false);\n      },\n      { threshold: INTERSECTION_THRESHOLD }\n    );\n\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n\n  useEffect(() => {\n    const handleVisibilityChange = () => {\n      setIsPageVisible(document.visibilityState === \"visible\");\n    };\n\n    handleVisibilityChange();\n    document.addEventListener(\"visibilitychange\", handleVisibilityChange);\n    return () =>\n      document.removeEventListener(\"visibilitychange\", handleVisibilityChange);\n  }, []);\n\n  useEffect(() => {\n    if (theme !== \"auto\" || typeof window === \"undefined\") {\n      return;\n    }\n\n    const mediaQuery = window.matchMedia(\"(prefers-color-scheme: dark)\");\n    setSystemPrefersDark(mediaQuery.matches);\n\n    const handleChange = (event: MediaQueryListEvent) => {\n      setSystemPrefersDark(event.matches);\n    };\n\n    mediaQuery.addEventListener(\"change\", handleChange);\n    return () => mediaQuery.removeEventListener(\"change\", handleChange);\n  }, [theme]);\n\n  useEffect(() => {\n    shouldPlayRef.current = hasStarted && isInView && isPageVisible;\n  }, [hasStarted, isInView, isPageVisible]);\n\n  useEffect(() => {\n    if (!shouldReduceMotion) {\n      return;\n    }\n\n    setCompletedLines(lines);\n    setTypingDraft(null);\n\n    if (!calledOnCompleteRef.current) {\n      calledOnCompleteRef.current = true;\n      onComplete?.();\n    }\n  }, [shouldReduceMotion, lines, onComplete]);\n\n  useEffect(() => {\n    if (shouldReduceMotion) {\n      return;\n    }\n\n    let loopTimeout = 0;\n\n    const tick = () => {\n      if (!shouldPlayRef.current) {\n        return;\n      }\n\n      const machine = machineRef.current;\n      if (machine.finished) {\n        return;\n      }\n\n      const currentLines = linesRef.current;\n      if (machine.lineIndex >= currentLines.length) {\n        machine.finished = true;\n        onCompleteRef.current?.();\n\n        if (loopRef.current) {\n          loopTimeout = window.setTimeout(() => {\n            machineRef.current = createMachine();\n            setCompletedLines([]);\n            setTypingDraft(null);\n          }, LOOP_PAUSE_MS);\n        }\n        return;\n      }\n\n      const line = currentLines[machine.lineIndex];\n      const isCommand = line.type === \"command\";\n      const delay = line.delay ?? (isCommand ? 0 : OUTPUT_DEFAULT_DELAY_MS);\n\n      machine.phaseElapsed += TICK_MS;\n\n      if (machine.phase === \"delay\") {\n        if (machine.phaseElapsed < delay) {\n          return;\n        }\n\n        if (isCommand) {\n          machine.phase = \"typing\";\n          machine.phaseElapsed = 0;\n          setTypingDraft({ chars: 0, line });\n        } else {\n          setCompletedLines((prev) => [...prev, line]);\n          machine.lineIndex += 1;\n          machine.phase = \"delay\";\n          machine.phaseElapsed = 0;\n        }\n        return;\n      }\n\n      const msPerChar = 1000 / typingSpeedRef.current;\n      const charsToShow = Math.min(\n        line.text.length,\n        Math.floor(machine.phaseElapsed / msPerChar)\n      );\n      setTypingDraft({ chars: charsToShow, line });\n\n      if (charsToShow >= line.text.length) {\n        setCompletedLines((prev) => [...prev, line]);\n        setTypingDraft(null);\n        machine.lineIndex += 1;\n        machine.phase = \"delay\";\n        machine.phaseElapsed = 0;\n      }\n    };\n\n    const interval = window.setInterval(tick, TICK_MS);\n    return () => {\n      window.clearInterval(interval);\n      window.clearTimeout(loopTimeout);\n    };\n  }, [shouldReduceMotion]);\n\n  useEffect(() => {\n    const el = scrollRef.current;\n    if (!el) {\n      return;\n    }\n    el.scrollTop = el.scrollHeight;\n  }, [completedLines, typingDraft]);\n\n  const handleActivate = useCallback(() => {\n    setHasStarted(true);\n  }, []);\n\n  const handleKeyDown = useCallback(\n    (event: KeyboardEvent<HTMLDivElement>) => {\n      if (!isInteractive) {\n        return;\n      }\n      if (event.key === KEY_ENTER || event.key === KEY_SPACE) {\n        event.preventDefault();\n        handleActivate();\n      }\n    },\n    [handleActivate, isInteractive]\n  );\n\n  const showIdleHint =\n    isInteractive && completedLines.length === 0 && !typingDraft;\n\n  return (\n    <div\n      aria-label={`${title} terminal`}\n      className={cn(\n        \"overflow-hidden rounded-xl border shadow-lg\",\n        styles.border,\n        isInteractive &&\n          \"cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2\",\n        className\n      )}\n      onClick={isInteractive ? handleActivate : undefined}\n      onKeyDown={isInteractive ? handleKeyDown : undefined}\n      ref={containerRef}\n      role={isInteractive ? \"button\" : \"group\"}\n      tabIndex={isInteractive ? 0 : undefined}\n    >\n      <div\n        className={cn(\n          \"flex items-center gap-3 border-b px-4 py-2.5\",\n          styles.chrome,\n          styles.border\n        )}\n      >\n        {showTrafficLights ? (\n          <div\n            aria-hidden={hasLightHandlers ? undefined : \"true\"}\n            className=\"flex items-center gap-2\"\n          >\n            {onClose ? (\n              <button\n                aria-label=\"Close terminal\"\n                className={cn(\n                  TRAFFIC_LIGHT_CLASS,\n                  \"bg-[#ff5f57] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500\"\n                )}\n                onClick={(event) => {\n                  event.stopPropagation();\n                  onClose();\n                }}\n                type=\"button\"\n              />\n            ) : (\n              <span\n                aria-hidden=\"true\"\n                className={cn(TRAFFIC_LIGHT_CLASS, \"bg-[#ff5f57]\")}\n              />\n            )}\n            {onMinimize ? (\n              <button\n                aria-label=\"Minimize terminal\"\n                className={cn(\n                  TRAFFIC_LIGHT_CLASS,\n                  \"bg-[#febc2e] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500\"\n                )}\n                onClick={(event) => {\n                  event.stopPropagation();\n                  onMinimize();\n                }}\n                type=\"button\"\n              />\n            ) : (\n              <span\n                aria-hidden=\"true\"\n                className={cn(TRAFFIC_LIGHT_CLASS, \"bg-[#febc2e]\")}\n              />\n            )}\n            <span\n              aria-hidden=\"true\"\n              className={cn(TRAFFIC_LIGHT_CLASS, \"bg-[#28c840]\")}\n            />\n          </div>\n        ) : null}\n        <p\n          className={cn(\n            \"flex-1 truncate text-center font-medium text-xs\",\n            styles.title\n          )}\n        >\n          {title}\n        </p>\n        {showTrafficLights ? (\n          <span aria-hidden=\"true\" className=\"w-14\" />\n        ) : null}\n      </div>\n\n      <div\n        className={cn(\n          \"overflow-y-auto px-4 py-3 font-mono text-xs leading-relaxed sm:text-sm\",\n          styles.body\n        )}\n        ref={scrollRef}\n        style={{ maxHeight: resolvedMaxHeight }}\n      >\n        <pre\n          aria-hidden={shouldReduceMotion ? undefined : \"true\"}\n          className=\"whitespace-pre-wrap break-words font-mono\"\n        >\n          {completedLines.map((line) => (\n            <div className=\"flex flex-wrap gap-2\" key={line.id}>\n              {line.type === \"command\" ? (\n                <>\n                  <span className={styles.prompt}>{prompt}</span>\n                  <span className={styles.command}>{line.text}</span>\n                </>\n              ) : (\n                <span className={getLineToneClass(line.type, styles)}>\n                  {line.text}\n                </span>\n              )}\n            </div>\n          ))}\n          {typingDraft ? (\n            <div className=\"flex flex-wrap gap-2\">\n              <span className={styles.prompt}>{prompt}</span>\n              <span className={styles.command}>\n                {typingDraft.line.text.slice(0, typingDraft.chars)}\n                <span\n                  aria-hidden=\"true\"\n                  className={cn(\n                    \"mac-terminal-caret ml-0.5 inline-block h-[1em] w-[0.55em] translate-y-[0.15em] align-middle\",\n                    styles.caret\n                  )}\n                />\n              </span>\n            </div>\n          ) : null}\n          {showIdleHint ? (\n            <span className={styles.comment}>\n              Click or press Enter to run this session\n            </span>\n          ) : null}\n        </pre>\n        {!shouldReduceMotion && (\n          <div aria-live=\"off\" className=\"sr-only\">\n            <pre className=\"whitespace-pre-wrap\">\n              {lines.map((line) => line.text).join(\"\\n\")}\n            </pre>\n          </div>\n        )}\n      </div>\n\n      {!shouldReduceMotion && (\n        <style>{`\n          @keyframes mac-terminal-caret-blink {\n            0%, 49% { opacity: 1; }\n            50%, 100% { opacity: 0; }\n          }\n          .mac-terminal-caret {\n            animation: mac-terminal-caret-blink 1s steps(1) infinite;\n          }\n        `}</style>\n      )}\n    </div>\n  );\n}\n","path":"index.tsx","target":"components/smoothui/mac-terminal/index.tsx","type":"registry:ui"}],"name":"mac-terminal","registryDependencies":[],"title":"Mac Terminal","type":"registry:ui"}