{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "log-view",
  "type": "registry:ui",
  "title": "Log view",
  "description": "A build's or a deploy's output as it arrives, on the code block's surface: numbered, timed lines, warnings and errors in their status color with a mark and a hidden word, following the newest line until you scroll up to read.",
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/button.json",
    "https://littmd.vercel.app/r/icons.json",
    "https://littmd.vercel.app/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/log-view.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/log-view 2026.11.06. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * LogView: the lines a build, a deploy or a job writes as it runs, as they arrive (components.md, \"Log view\"). The\n * CodeBlock's surface, for output instead of source.\n *\n * - Surface: --litt-surface-2 with an inset --litt-line hairline at the surface radius, as the CodeBlock's. A 32px header holds\n *   the label in the mono at the caption size in --litt-fg-subtle, your actions, and Copy. The lines are the mono at the\n *   small size, padded 16px, under the header.\n * - Flat: a grey card (--litt-surface-3) with no hairline, and Copy a round white key.\n * - A line: its number in a gutter (mono caption, --litt-fg-subtle, never selected or copied), its time when it has one\n *   (in one right-aligned column as wide as the longest time, so the text stays in line as \"+9.6s\" becomes \"+10.2s\"),\n *   then the text. Info is --litt-fg-muted, debug --litt-fg-subtle. A warning's text is --litt-warning and an error's --litt-danger, each\n *   with the alert mark in the gutter and a hidden \"Warning:\" or \"Error:\" before it, so color is never the only\n *   signal; success is --litt-success and says so in its own words.\n * - Following: while the view is at the bottom, new lines keep it there, as a terminal does. Scroll up to read and it\n *   stays put; a \"Jump to latest\" button appears at the bottom with how many lines came since, and takes you back\n *   (smoothly, or at once under reduced motion) and follows again.\n * - Long lines scroll sideways inside the view; `wrap` wraps them. Lines out of view skip their layout\n *   (content-visibility), so a few thousand stay cheap; past `maxLines` the oldest go.\n * - Accessibility: the scroller is a region named by the label and a Tab stop, so the arrow keys scroll it. Lines are\n *   an ordered list. New lines are not read out one by one; say how the run ended in your own status (a Progress, a\n *   Toast) when it does.\n * - Motion: none, apart from the jump back, which is the browser's own smooth scroll.\n */\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from \"react\";\nimport { useMotionDial } from \"../../lib/litt/motion\";\nimport { Button } from \"./button\";\nimport { AlertIcon, ArrowDownIcon, CheckIcon, CopyIcon } from \"./icons\";\n\nexport type LogLevel = \"debug\" | \"info\" | \"success\" | \"warn\" | \"error\";\n\nexport type LogLine = {\n  /** A stable key, when lines can be dropped or replaced. Default: its place in the list. */\n  id?: string | number;\n  /** When it was written, as you want it shown: \"12:04:31\", \"+3.2s\". */\n  time?: string;\n  /** How it reads: debug is quiet, info is the default, success, warn and error take their status color and words. */\n  level?: LogLevel;\n  /** The line itself. */\n  text: string;\n};\n\nconst TEXT: Record<LogLevel, string> = {\n  debug: \"text-litt-fg-subtle\",\n  info: \"text-litt-fg-muted\",\n  success: \"text-litt-success\",\n  warn: \"text-litt-warning\",\n  error: \"text-litt-danger\",\n};\nconst SAID: Partial<Record<LogLevel, string>> = { warn: \"Warning: \", error: \"Error: \" };\n\n/** Within this many px of the bottom counts as at the bottom. */\nconst NEAR = 8;\nconst FEEDBACK_MS = 1600;\n\nexport type LogViewProps = {\n  /** What wrote it, shown in the header and naming the region: \"Build log\". */\n  label: string;\n  /** The lines so far, oldest first. Append to it as the run writes. */\n  lines: LogLine[];\n  /** Keep the view at the newest line while it is at the bottom. */\n  follow?: boolean;\n  /** Wrap long lines instead of scrolling them sideways. */\n  wrap?: boolean;\n  /** Show the line numbers. */\n  lineNumbers?: boolean;\n  /** Keep only this many of the newest lines. */\n  maxLines?: number;\n  /** Shown when there are no lines yet. */\n  empty?: ReactNode;\n  /** Small ghost buttons for the header, before Copy: a filter, Download. */\n  actions?: ReactNode;\n  /** The height of the lines area, which scrolls inside: a number is px, a string any CSS length. \"fill\" takes what is left of the view's own height, so give the view one (h-full in a panel). */\n  height?: number | string;\n  /** Extra classes for the view. */\n  className?: string;\n};\n\n/** A run's output as it arrives: numbered lines, warnings and errors marked in words, and following the newest line. */\nexport function LogView({\n  label,\n  lines: allLines,\n  follow = true,\n  wrap = false,\n  lineNumbers = true,\n  maxLines = 5000,\n  empty = \"Waiting for output.\",\n  actions,\n  height = \"20rem\",\n  className = \"\",\n}: LogViewProps) {\n  const { reduced } = useMotionDial();\n  const lines = useMemo(() => (allLines.length > maxLines ? allLines.slice(-maxLines) : allLines), [allLines, maxLines]);\n  const firstNumber = allLines.length - lines.length + 1;\n  const scroller = useRef<HTMLDivElement>(null);\n  const [atBottom, setAtBottom] = useState(true);\n  const bottom = useRef(true);\n  /* How many lines there were when the person scrolled away, to say how many came since. */\n  const [seen, setSeen] = useState(allLines.length);\n\n  const onScroll = () => {\n    const el = scroller.current;\n    if (!el) return;\n    const now = el.scrollHeight - el.scrollTop - el.clientHeight <= NEAR;\n    if (now !== bottom.current) {\n      bottom.current = now;\n      setAtBottom(now);\n      if (!now) setSeen(allLines.length);\n    }\n  };\n\n  /* New lines while at the bottom keep the view there. */\n  useLayoutEffect(() => {\n    const el = scroller.current;\n    if (!el || !follow || !bottom.current) return;\n    el.scrollTop = el.scrollHeight;\n  }, [allLines.length, follow]);\n\n  const toLatest = () => {\n    const el = scroller.current;\n    if (!el) return;\n    bottom.current = true;\n    setAtBottom(true);\n    el.scrollTo({ top: el.scrollHeight, behavior: reduced ? \"auto\" : \"smooth\" });\n  };\n\n  const fresh = Math.max(0, allLines.length - seen);\n  const digits = String(firstNumber + lines.length).length;\n  /* The times share one column, as wide as the longest and right-aligned, so \"+9.6s\" and \"+10.2s\" keep the text in line. */\n  const timeWidth = lines.reduce((w, l) => Math.max(w, l.time?.length ?? 0), 0);\n\n  const [copied, setCopied] = useState<\"idle\" | \"copied\" | \"failed\">(\"idle\");\n  const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n  useEffect(() => () => clearTimeout(timer.current), []);\n  const copy = useCallback(() => {\n    const text = lines.map((l) => (l.time ? `${l.time}  ${l.text}` : l.text)).join(\"\\n\");\n    const done = (state: \"copied\" | \"failed\") => {\n      setCopied(state);\n      clearTimeout(timer.current);\n      timer.current = setTimeout(() => setCopied(\"idle\"), FEEDBACK_MS);\n    };\n    if (!navigator.clipboard?.writeText) return done(\"failed\");\n    navigator.clipboard.writeText(text).then(\n      () => done(\"copied\"),\n      () => done(\"failed\"),\n    );\n  }, [lines]);\n\n  return (\n    <div className={\"relative grid min-w-0 max-w-full grid-rows-[auto_minmax(0,1fr)] rounded-litt-surface bg-litt-surface-2 in-[[data-material=flat]]:bg-litt-surface-3 \" + className}>\n      <div className=\"flex h-litt-control-sm items-center justify-between gap-2 pr-1 pl-4 pointer-coarse:h-litt-control-md\">\n        <span className=\"min-w-0 truncate text-litt-caption font-normal text-litt-fg-subtle\">{label}</span>\n        <div className=\"flex shrink-0 items-center gap-1\">\n          {actions}\n          <Button\n            iconOnly\n            variant=\"ghost\"\n            size=\"sm\"\n            aria-label=\"Copy log\"\n            disabled={!lines.length}\n            onClick={copy}\n            className=\"focus-visible:outline-offset-[-2px] pointer-coarse:h-litt-control-md pointer-coarse:w-litt-control-md in-[[data-material=flat]]:rounded-litt-pill in-[[data-material=flat]]:bg-litt-surface-1 in-[[data-material=flat]]:hover:bg-litt-surface-1\"\n          >\n            {copied === \"copied\" ? <CheckIcon /> : <CopyIcon />}\n          </Button>\n        </div>\n      </div>\n\n      <div\n        ref={scroller}\n        role=\"region\"\n        aria-label={label}\n        tabIndex={0}\n        onScroll={onScroll}\n        style={height === \"fill\" ? undefined : { height }}\n        className=\"min-h-0 overflow-auto overscroll-contain rounded-b-litt-surface py-4 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-litt-focus\"\n      >\n        {lines.length === 0 ? (\n          <p className=\"px-4 font-litt-mono text-litt-small font-normal text-litt-fg-subtle\">{empty}</p>\n        ) : (\n          <ol\n            style={{ \"--log-gutter\": `${digits}ch`, \"--log-time\": `${timeWidth}ch` } as CSSProperties}\n            className={\"m-0 list-none p-0 font-litt-mono text-litt-small font-normal \" + (wrap ? \"w-full\" : \"w-max min-w-full\")}\n          >\n            {lines.map((line, i) => {\n              const level = line.level ?? \"info\";\n              const marked = level === \"warn\" || level === \"error\";\n              return (\n                <li key={line.id ?? firstNumber + i} className=\"flex items-baseline gap-3 px-4 [contain-intrinsic-size:auto_1.25rem] [content-visibility:auto]\">\n                  {/* The gutter: the number, then a 12px cell for a warning's or an error's mark. */}\n                  <span aria-hidden=\"true\" className=\"flex shrink-0 items-baseline gap-1.5 select-none\">\n                    {lineNumbers && <span className=\"w-(--log-gutter) text-right text-litt-caption text-litt-fg-subtle tabular-nums\">{firstNumber + i}</span>}\n                    <span className={\"inline-grid w-3 translate-y-0.5 place-items-center \" + TEXT[level]}>{marked && <AlertIcon size={12} />}</span>\n                  </span>\n                  {timeWidth > 0 && <span className=\"w-(--log-time) shrink-0 text-right text-litt-caption text-litt-fg-subtle tabular-nums\">{line.time}</span>}\n                  <span className={\"min-w-0 grow \" + TEXT[level] + \" \" + (wrap ? \"whitespace-pre-wrap [overflow-wrap:anywhere]\" : \"whitespace-pre\")}>\n                    {SAID[level] && <span className=\"sr-only select-none\">{SAID[level]}</span>}\n                    {line.text}\n                  </span>\n                </li>\n              );\n            })}\n          </ol>\n        )}\n      </div>\n\n      {!atBottom && (\n        <div className=\"pointer-events-none absolute inset-x-0 bottom-3 flex justify-center\">\n          <Button size=\"sm\" icon={<ArrowDownIcon />} onClick={toLatest} className=\"pointer-events-auto\">\n            {fresh > 0 ? `${fresh} new ${fresh === 1 ? \"line\" : \"lines\"}` : \"Jump to latest\"}\n          </Button>\n        </div>\n      )}\n\n      <span aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 rounded-litt-surface shadow-[inset_0_0_0_1px_var(--litt-line)] in-[[data-material=flat]]:hidden\" />\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {copied === \"copied\" ? \"Copied\" : copied === \"failed\" ? \"Copy failed. Select the lines and copy them by hand.\" : \"\"}\n      </span>\n    </div>\n  );\n}\n",
      "target": "components/litt/log-view.tsx"
    }
  ],
  "docs": "Import LogView and LogLine from components/litt/log-view. Append lines ({ time, level, text }) as the run writes, and give it a height. Say how the run ended in your own status; lines are not read out one by one.",
  "meta": {
    "version": "2026.11.06",
    "released": "2026-11-06",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
