{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "log-viewer",
  "type": "registry:ui",
  "title": "Log Viewer",
  "description": "Streaming logs that follow the tail, pause when you scroll up, and count what you missed.",
  "categories": [
    "developer"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/log-viewer.tsx",
      "content": "\"use client\";\nimport { Toggle } from \"@base-ui/react/toggle\";\nimport { ToggleGroup } from \"@base-ui/react/toggle-group\";\nimport { Tooltip } from \"@base-ui/react/tooltip\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ArrowDown, Search, X } from \"@/lib/icons\";\nimport { spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type LogLevel = \"debug\" | \"info\" | \"warn\" | \"error\";\n\nexport type LogLine = {\n  /** Stable and unique: new lines are told apart from old ones by id. */\n  id: string | number;\n  /** Epoch milliseconds, a Date, or an ISO string. */\n  time: number | Date | string;\n  level: LogLevel;\n  message: string;\n  /** Where it came from: a service, a worker, a region. */\n  source?: string;\n};\n\nconst LEVELS: LogLevel[] = [\"error\", \"warn\", \"info\", \"debug\"];\nconst LEVEL_LABEL: Record<LogLevel, string> = { error: \"Error\", warn: \"Warn\", info: \"Info\", debug: \"Debug\" };\nconst LEVEL_TAG: Record<LogLevel, string> = { error: \"ERR\", warn: \"WRN\", info: \"INF\", debug: \"DBG\" };\nconst LEVEL_TEXT: Record<LogLevel, string> = { error: \"text-danger\", warn: \"text-warning\", info: \"text-fg-3\", debug: \"text-fg-4\" };\nconst LEVEL_DOT: Record<LogLevel, string> = { error: \"bg-danger\", warn: \"bg-warning\", info: \"bg-fg-3\", debug: \"bg-fg-4\" };\n\nconst pad = (n: number, w = 2) => String(n).padStart(w, \"0\");\n/** 14:03:12.408 in the reader's own time zone. */\nexport function formatLogTime(t: LogLine[\"time\"]) {\n  const d = t instanceof Date ? t : new Date(t);\n  if (Number.isNaN(d.getTime())) return \"--:--:--.---\";\n  return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`;\n}\n\nfunction Marked({ text, query }: { text: string; query: string }) {\n  const q = query.trim().toLowerCase();\n  if (!q) return <>{text}</>;\n  const lower = text.toLowerCase();\n  const out: React.ReactNode[] = [];\n  let at = 0;\n  for (let i = lower.indexOf(q); i !== -1; i = lower.indexOf(q, at)) {\n    if (i > at) out.push(text.slice(at, i));\n    out.push(\n      <mark key={i} className=\"rounded-[2px] bg-fg/15 text-fg\">\n        {text.slice(i, i + q.length)}\n      </mark>,\n    );\n    at = i + q.length;\n  }\n  if (at < text.length) out.push(text.slice(at));\n  return <>{out}</>;\n}\n\nexport type LogViewerProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  lines: LogLine[];\n  /** New lines are still arriving: shows the live dot and the waiting state. */\n  streaming?: boolean;\n  /** Stick to the newest line (controlled). Scrolling up turns it off; scrolling back to the end turns it on. */\n  follow?: boolean;\n  defaultFollow?: boolean;\n  onFollowChange?: (follow: boolean) => void;\n  /** Soft-wrap long lines instead of scrolling sideways. */\n  wrap?: boolean;\n  defaultWrap?: boolean;\n  onWrapChange?: (wrap: boolean) => void;\n  /** Levels shown (controlled). */\n  levels?: LogLevel[];\n  defaultLevels?: LogLevel[];\n  onLevelsChange?: (levels: LogLevel[]) => void;\n  /** Filter text (controlled). Lines that don't contain it are hidden; matches are marked. */\n  query?: string;\n  defaultQuery?: string;\n  onQueryChange?: (query: string) => void;\n  /** Height of the scrolling region. */\n  height?: number | string;\n  /** Skeleton lines while the first page of logs loads. */\n  loading?: boolean;\n  /** Names the log for screen readers. */\n  label?: string;\n  /** Overrides the timestamp format. Defaults to HH:MM:SS.mmm in local time. */\n  formatTime?: (time: LogLine[\"time\"]) => string;\n};\n\nexport function LogViewer({\n  lines,\n  streaming = false,\n  follow: followProp,\n  defaultFollow = true,\n  onFollowChange,\n  wrap: wrapProp,\n  defaultWrap = false,\n  onWrapChange,\n  levels: levelsProp,\n  defaultLevels = LEVELS,\n  onLevelsChange,\n  query: queryProp,\n  defaultQuery = \"\",\n  onQueryChange,\n  height = 320,\n  loading = false,\n  label = \"Logs\",\n  formatTime = formatLogTime,\n  className,\n  ...rest\n}: LogViewerProps) {\n  const reduce = useReducedMotion();\n  const [follow, setFollow] = useControllableState({ value: followProp, defaultValue: defaultFollow, onChange: onFollowChange });\n  const [wrap, setWrap] = useControllableState({ value: wrapProp, defaultValue: defaultWrap, onChange: onWrapChange });\n  const [levels, setLevels] = useControllableState<LogLevel[]>({ value: levelsProp, defaultValue: defaultLevels, onChange: onLevelsChange });\n  const [query, setQuery] = useControllableState({ value: queryProp, defaultValue: defaultQuery, onChange: onQueryChange });\n  const scroller = useRef<HTMLDivElement>(null);\n  const searchRef = useRef<HTMLInputElement>(null);\n  const lastTop = useRef(0);\n  const [tip] = useState(() => Tooltip.createHandle<string>());\n  // Lines on screen at mount don't fade in; everything after does.\n  const [initial] = useState(() => new Set(lines.map((l) => l.id)));\n  // The newest line seen before following stopped, so the pill can count what came after.\n  const [pausedAt, setPausedAt] = useState<LogLine[\"id\"] | null>(null);\n\n  const q = query.trim().toLowerCase();\n  const matching = useMemo(() => (q ? lines.filter((l) => l.message.toLowerCase().includes(q) || l.source?.toLowerCase().includes(q)) : lines), [lines, q]);\n  const counts = useMemo(() => {\n    const c: Record<LogLevel, number> = { error: 0, warn: 0, info: 0, debug: 0 };\n    for (const l of matching) c[l.level]++;\n    return c;\n  }, [matching]);\n  const shown = useMemo(() => matching.filter((l) => levels.includes(l.level)), [matching, levels]);\n  const filtered = !!q || levels.length < LEVELS.length;\n\n  const newCount = useMemo(() => {\n    if (follow || pausedAt === null) return 0;\n    const i = shown.findIndex((l) => l.id === pausedAt);\n    return i === -1 ? 0 : shown.length - 1 - i;\n  }, [follow, pausedAt, shown]);\n\n  // While following, every new line keeps the newest one in view, before paint.\n  const lastId = shown.length ? shown[shown.length - 1].id : null;\n  useLayoutEffect(() => {\n    const el = scroller.current;\n    if (!el || !follow) return;\n    el.scrollTop = el.scrollHeight;\n    lastTop.current = el.scrollTop;\n  }, [lastId, follow, wrap, shown.length]);\n\n  const pause = () => {\n    setPausedAt(lastId);\n    setFollow(false);\n  };\n  const resume = () => {\n    setPausedAt(null);\n    setFollow(true);\n  };\n\n  // Only a person scrolls up. Scrolling up pauses; landing on the end again resumes.\n  const onScroll = () => {\n    const el = scroller.current;\n    if (!el) return;\n    const up = el.scrollTop < lastTop.current - 1;\n    const down = el.scrollTop > lastTop.current + 1;\n    lastTop.current = el.scrollTop;\n    const atEnd = el.scrollHeight - el.scrollTop - el.clientHeight < 8;\n    if (follow && up && !atEnd) pause();\n    else if (!follow && down && atEnd) resume();\n  };\n\n  // Resuming lands on the newest line on the next frame (the effect above does the scroll).\n  const jumpToEnd = () => resume();\n\n  const clearFilters = () => {\n    setQuery(\"\");\n    setLevels(LEVELS);\n  };\n\n  const live = streaming && follow;\n\n  return (\n    <Tooltip.Provider delay={500}>\n      <div\n        data-slot=\"log-viewer\"\n        data-state={loading ? \"loading\" : live ? \"live\" : follow ? \"following\" : \"paused\"}\n        aria-busy={loading || undefined}\n        className={cn(\"flex min-w-0 flex-col overflow-hidden rounded-xl border border-line bg-raised text-fg shadow-[var(--shadow)]\", className)}\n        {...rest}\n      >\n        <div className=\"flex h-10 items-center gap-1 border-b border-line pl-2.5 pr-1.5\">\n          <Search size={14} className=\"shrink-0 text-fg-3\" />\n          <input\n            ref={searchRef}\n            type=\"search\"\n            value={query}\n            onChange={(e) => setQuery(e.target.value)}\n            onKeyDown={(e) => {\n              if (e.key === \"Escape\" && query) {\n                e.preventDefault();\n                setQuery(\"\");\n              }\n            }}\n            placeholder=\"Filter lines\"\n            aria-label={`Filter ${label.toLowerCase()}`}\n            enterKeyHint=\"search\"\n            spellCheck={false}\n            autoComplete=\"off\"\n            className=\"h-full min-w-0 flex-1 bg-transparent pl-1.5 text-base text-fg outline-none placeholder:text-fg-4 sm:text-[12.5px] [&::-webkit-search-cancel-button]:hidden\"\n          />\n          {query && (\n            <IconButton label=\"Clear filter\" tip={tip} onClick={() => { setQuery(\"\"); searchRef.current?.focus(); }}>\n              <X size={14} />\n            </IconButton>\n          )}\n          <Tooltip.Trigger\n            handle={tip}\n            payload={wrap ? \"Don’t wrap lines\" : \"Wrap lines\"}\n            render={\n              <Toggle\n                pressed={wrap}\n                onPressedChange={setWrap}\n                aria-label=\"Wrap lines\"\n                className={cn(iconButton, \"data-pressed:bg-fg/[0.08] data-pressed:text-fg\")}\n              />\n            }\n          >\n            <svg width={14} height={14} viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n              <path d=\"M2.75 4h10.5M2.75 8h8.75a1.75 1.75 0 0 1 0 3.5H8.5M2.75 12h3M10 10l-1.5 1.5L10 13\" />\n            </svg>\n          </Tooltip.Trigger>\n          <Toggle\n            pressed={follow}\n            onPressedChange={(p) => (p ? jumpToEnd() : pause())}\n            className={cn(\n              \"relative flex h-7 shrink-0 items-center gap-1.5 rounded-md px-2 text-[12px] font-medium text-fg-3 outline-none\",\n              \"transition-[background-color,color,scale] duration-150 ease-out hover:bg-fg/[0.06] hover:text-fg active:scale-[0.96] active:duration-75\",\n              \"focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3 focus-visible:outline-solid\",\n              \"data-pressed:bg-fg/[0.08] data-pressed:text-fg\",\n            )}\n          >\n            <span aria-hidden className=\"relative grid size-2 place-items-center\">\n              {live && <span className=\"absolute inset-0 animate-ping-soft rounded-full bg-success motion-reduce:hidden\" />}\n              <span className={cn(\"size-1.5 rounded-full transition-colors duration-200\", live ? \"bg-success\" : follow ? \"bg-fg-3\" : \"bg-fg-4\")} />\n            </span>\n            Follow\n          </Toggle>\n        </div>\n\n        <div className=\"flex h-9 items-center gap-2 border-b border-line px-1.5\">\n          <ToggleGroup\n            multiple\n            value={levels}\n            onValueChange={(v) => setLevels(v as LogLevel[])}\n            aria-label=\"Levels\"\n            className=\"flex min-w-0 items-center gap-0.5 overflow-x-auto px-0.5 py-1 [scrollbar-width:none]\"\n          >\n            {LEVELS.map((lv) => (\n              <Toggle\n                key={lv}\n                value={lv}\n                data-empty={counts[lv] === 0 || undefined}\n                aria-label={`${LEVEL_LABEL[lv]}, ${counts[lv]} ${counts[lv] === 1 ? \"line\" : \"lines\"}`}\n                className={cn(\n                  \"group/chip relative flex h-6 shrink-0 items-center gap-1.5 rounded-md px-1.5 text-[12px] text-fg-4 outline-none\",\n                  \"transition-[background-color,color,scale] duration-150 ease-out hover:bg-fg/[0.05] hover:text-fg-2 active:scale-[0.95] active:duration-75\",\n                  \"focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3 focus-visible:outline-solid\",\n                  \"data-pressed:text-fg data-pressed:data-empty:text-fg-3\",\n                )}\n              >\n                <span\n                  aria-hidden\n                  className={cn(\n                    \"size-1.5 rounded-full transition-[opacity,scale] duration-200 ease-out-expo\",\n                    LEVEL_DOT[lv],\n                    \"scale-75 opacity-40 group-data-pressed/chip:scale-100 group-data-pressed/chip:opacity-100\",\n                  )}\n                />\n                {LEVEL_LABEL[lv]}\n                <span className=\"tabular min-w-3 font-mono text-[11px] text-fg-4 group-data-pressed/chip:text-fg-3\">\n                  <NumberFlow value={counts[lv]} animated={!reduce} willChange />\n                </span>\n              </Toggle>\n            ))}\n          </ToggleGroup>\n          <span className=\"tabular ml-auto hidden shrink-0 pr-1.5 font-mono text-[11px] text-fg-4 sm:block\" aria-live=\"polite\">\n            {filtered ? `${shown.length} of ${lines.length}` : `${lines.length} ${lines.length === 1 ? \"line\" : \"lines\"}`}\n          </span>\n        </div>\n\n        <div className=\"relative min-h-0\">\n          <div\n            ref={scroller}\n            role=\"log\"\n            aria-label={label}\n            aria-live=\"off\"\n            tabIndex={0}\n            onScroll={onScroll}\n            onKeyDown={(e) => {\n              if (e.key === \"/\" && !e.metaKey && !e.ctrlKey) {\n                e.preventDefault();\n                searchRef.current?.focus();\n              }\n            }}\n            className={cn(\n              \"overflow-auto overscroll-contain py-1.5 font-mono text-[12px] leading-5 outline-none\",\n              \"focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3 focus-visible:outline-solid\",\n            )}\n            style={{ height }}\n          >\n            {loading ? (\n              <div aria-hidden className=\"flex flex-col gap-[7px] px-3 py-1.5\">\n                {[72, 54, 88, 40, 66, 58, 80].map((w, i) => (\n                  <div key={i} className=\"flex items-center gap-3\">\n                    <span className=\"h-2.5 w-[84px] shrink-0 animate-pulse-soft rounded-sm bg-fg/[0.06]\" />\n                    <span className=\"h-2.5 animate-pulse-soft rounded-sm bg-fg/[0.06]\" style={{ width: `${w - 30}%` }} />\n                  </div>\n                ))}\n              </div>\n            ) : !lines.length ? (\n              <div className=\"flex h-full flex-col items-center justify-center gap-2 font-sans text-[12.5px] text-fg-3\">\n                {streaming ? (\n                  <>\n                    <span aria-hidden className=\"size-1.5 animate-pulse-soft rounded-full bg-fg-3\" />\n                    Waiting for logs…\n                  </>\n                ) : (\n                  \"No logs yet\"\n                )}\n              </div>\n            ) : !shown.length ? (\n              <div className=\"flex h-full flex-col items-center justify-center gap-2.5 px-6 text-center font-sans\">\n                <p className=\"text-[12.5px] text-fg-2\">\n                  {q ? (\n                    <>\n                      No lines match <span className=\"text-fg\">“{query.trim()}”</span>\n                    </>\n                  ) : (\n                    \"No lines at these levels\"\n                  )}\n                </p>\n                <button\n                  type=\"button\"\n                  onClick={clearFilters}\n                  className=\"h-7 rounded-md border border-line-2 bg-raised px-2.5 text-[12px] font-medium text-fg shadow-[var(--shadow)] outline-none transition-[background-color,border-color,scale] duration-150 ease-out hover:border-fg-4 hover:bg-hover active:scale-[0.97] focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3 focus-visible:outline-solid\"\n                >\n                  Clear filters\n                </button>\n              </div>\n            ) : (\n              <div className={cn(\"flex flex-col\", !wrap && \"w-max min-w-full\")}>\n                {shown.map((l) => (\n                  <div\n                    key={l.id}\n                    data-level={l.level}\n                    className={cn(\n                      \"grid grid-cols-[auto_auto_1fr] gap-x-3 px-3 hover:bg-fg/[0.035] [contain-intrinsic-size:auto_20px] [content-visibility:auto]\",\n                      l.level === \"error\" && \"bg-danger-soft/70 shadow-[inset_2px_0_0_var(--danger)] hover:bg-danger-soft\",\n                      l.level === \"warn\" && \"shadow-[inset_2px_0_0_var(--warning)]\",\n                      !initial.has(l.id) && \"transition-opacity duration-200 ease-out starting:opacity-0\",\n                    )}\n                  >\n                    <time className=\"tabular select-none text-fg-4\" suppressHydrationWarning>\n                      {formatTime(l.time)}\n                    </time>\n                    <span className={cn(\"select-none\", LEVEL_TEXT[l.level])}>\n                      <span aria-hidden>{LEVEL_TAG[l.level]}</span>\n                      <span className=\"sr-only\">{LEVEL_LABEL[l.level]}</span>\n                    </span>\n                    <span className={cn(\"min-w-0\", wrap ? \"whitespace-pre-wrap [overflow-wrap:anywhere]\" : \"whitespace-pre\", l.level === \"error\" ? \"text-fg\" : \"text-fg-2\")}>\n                      {l.source && (\n                        <span className=\"text-fg-3\">\n                          <Marked text={l.source} query={query} />{\" \"}\n                        </span>\n                      )}\n                      <Marked text={l.message} query={query} />\n                    </span>\n                  </div>\n                ))}\n              </div>\n            )}\n          </div>\n\n          <AnimatePresence>\n            {!follow && shown.length > 0 && (\n              <motion.button\n                key=\"jump\"\n                type=\"button\"\n                onClick={() => {\n                  jumpToEnd();\n                  // The pill leaves as it's pressed; focus lands on the log it scrolled.\n                  scroller.current?.focus({ preventScroll: true });\n                }}\n                initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.96 }}\n                animate={{ opacity: 1, y: 0, scale: 1 }}\n                exit={reduce ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.97, transition: { duration: 0.14 } }}\n                transition={reduce ? { duration: 0.15 } : spring.snappy}\n                className={cn(\n                  \"absolute bottom-3 left-1/2 flex h-7 -translate-x-1/2 items-center gap-1.5 rounded-full bg-fg pl-2.5 pr-3 text-[12px] font-medium text-frame shadow-pop outline-none\",\n                  \"transition-[background-color] duration-150 hover:bg-fg/90 active:scale-[0.96]\",\n                  \"focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3 focus-visible:outline-solid\",\n                )}\n              >\n                <ArrowDown size={13} />\n                {newCount > 0 ? (\n                  <span className=\"tabular flex items-center gap-1\">\n                    <NumberFlow value={newCount} animated={!reduce} willChange />\n                    {newCount === 1 ? \"new line\" : \"new lines\"}\n                  </span>\n                ) : (\n                  \"Jump to latest\"\n                )}\n              </motion.button>\n            )}\n          </AnimatePresence>\n        </div>\n      </div>\n\n      <Tooltip.Root handle={tip}>\n        {({ payload }) => (\n          <Tooltip.Portal>\n            <Tooltip.Positioner side=\"top\" sideOffset={6} collisionPadding={8} className=\"z-(--z-tooltip)\">\n              <Tooltip.Popup\n                className={cn(\n                  \"flex min-h-6 items-center rounded-md border border-line-2 bg-raised px-2 py-1 text-[12px] leading-4 text-fg shadow-pop\",\n                  \"origin-(--transform-origin) transition-[opacity,scale,translate] duration-150 ease-out-expo\",\n                  \"data-starting-style:translate-y-0.5 data-starting-style:scale-96 data-starting-style:opacity-0 data-ending-style:scale-98 data-ending-style:opacity-0 data-ending-style:duration-100\",\n                  \"data-instant:duration-0\",\n                )}\n              >\n                {payload}\n              </Tooltip.Popup>\n            </Tooltip.Positioner>\n          </Tooltip.Portal>\n        )}\n      </Tooltip.Root>\n    </Tooltip.Provider>\n  );\n}\n\nconst iconButton = cn(\n  \"relative grid size-7 shrink-0 place-items-center rounded-md text-fg-3 outline-none\",\n  \"transition-[background-color,color,scale] duration-150 ease-out hover:bg-fg/[0.06] hover:text-fg active:scale-[0.92] active:duration-75\",\n  \"focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3 focus-visible:outline-solid\",\n  \"before:absolute before:-inset-1.5 before:content-[''] pointer-fine:before:hidden\",\n);\n\nfunction IconButton({ label, tip, onClick, children }: { label: string; tip: ReturnType<typeof Tooltip.createHandle<string>>; onClick: () => void; children: React.ReactNode }) {\n  return (\n    <Tooltip.Trigger handle={tip} payload={label} render={<button type=\"button\" aria-label={label} onClick={onClick} className={iconButton} />}>\n      {children}\n    </Tooltip.Trigger>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/log-viewer"
}
