{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "json-viewer",
  "type": "registry:ui",
  "title": "JSON Viewer",
  "description": "Folding JSON tree with search that unfolds to every match, and copyable paths.",
  "categories": [
    "developer"
  ],
  "dependencies": [
    "@base-ui/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/copy-button.json"
  ],
  "files": [
    {
      "path": "components/ui/json-viewer.tsx",
      "content": "\"use client\";\nimport { Tooltip } from \"@base-ui/react/tooltip\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { useCopy } from \"@/components/ui/copy-button\";\nimport { cn } from \"@/lib/cn\";\nimport { ChevronDown, ChevronRight, ChevronUp, Copy, Link, Search, X } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\n\ntype Kind = \"object\" | \"array\" | \"string\" | \"number\" | \"boolean\" | \"null\" | \"other\";\n\ntype Node = {\n  /** The node's path, which is also its id: `users[0].email`, `headers[\"content-type\"]`. The root is \"\". */\n  id: string;\n  key: string | number | null;\n  value: unknown;\n  kind: Kind;\n  depth: number;\n  parent: Node | null;\n  children: Node[];\n};\n\ntype Row = { type: \"node\"; node: Node } | { type: \"more\"; id: string; parent: Node; shown: number };\n\nconst IDENT = /^[A-Za-z_$][\\w$]*$/;\nconst INDENT = 14;\n\nconst kindOf = (v: unknown): Kind =>\n  v === null\n    ? \"null\"\n    : Array.isArray(v)\n      ? \"array\"\n      : typeof v === \"object\"\n        ? \"object\"\n        : typeof v === \"string\" || typeof v === \"number\" || typeof v === \"boolean\"\n          ? (typeof v as \"string\" | \"number\" | \"boolean\")\n          : \"other\";\n\n/** Formats a child path the way you'd write it in code: `a.b`, `a[0]`, `a[\"x-y\"]`. */\nexport function childPath(parent: string, key: string | number) {\n  if (typeof key === \"number\") return `${parent}[${key}]`;\n  if (IDENT.test(key)) return parent ? `${parent}.${key}` : key;\n  return `${parent}[${JSON.stringify(key)}]`;\n}\n\nfunction build(value: unknown, key: string | number | null, parent: Node | null): Node {\n  const node: Node = {\n    id: key === null ? \"\" : childPath(parent?.id ?? \"\", key),\n    key,\n    value,\n    kind: kindOf(value),\n    depth: parent ? parent.depth + 1 : -1,\n    parent,\n    children: [],\n  };\n  if (node.kind === \"array\") node.children = (value as unknown[]).map((v, i) => build(v, i, node));\n  else if (node.kind === \"object\") node.children = Object.entries(value as object).map(([k, v]) => build(v, k, node));\n  return node;\n}\n\nconst isBranch = (n: Node) => n.kind === \"object\" || n.kind === \"array\";\n\n/** The text a primitive shows, JSON-escaped so a newline reads as \\n rather than breaking the row. */\nfunction display(n: Node) {\n  if (n.kind === \"string\") return JSON.stringify(n.value as string).slice(1, -1);\n  if (n.kind === \"other\") return String(n.value);\n  return String(n.value);\n}\n\nfunction walk(n: Node, visit: (n: Node) => void) {\n  visit(n);\n  for (const c of n.children) walk(c, visit);\n}\n\nfunction toDepth(root: Node, depth: number) {\n  const out: string[] = [];\n  walk(root, (n) => {\n    if (isBranch(n) && n.depth < depth - 1 && n.children.length) out.push(n.id);\n  });\n  return out;\n}\n\nfunction findMatches(root: Node, q: string) {\n  const needle = q.trim().toLowerCase();\n  const out: string[] = [];\n  if (!needle) return out;\n  walk(root, (n) => {\n    if (n === root) return;\n    const inKey = typeof n.key === \"string\" && n.key.toLowerCase().includes(needle);\n    const inValue = !isBranch(n) && display(n).toLowerCase().includes(needle);\n    if (inKey || inValue) out.push(n.id);\n  });\n  return out;\n}\n\nconst count = (n: Node) =>\n  n.kind === \"array\"\n    ? `${n.children.length} ${n.children.length === 1 ? \"item\" : \"items\"}`\n    : `${n.children.length} ${n.children.length === 1 ? \"key\" : \"keys\"}`;\n\n/** A one-line glance at a folded node: its keys, or its first values. */\nfunction preview(n: Node) {\n  const parts = n.children.slice(0, 5).map((c) =>\n    n.kind === \"object\" ? String(c.key) : isBranch(c) ? (c.kind === \"array\" ? \"[…]\" : \"{…}\") : c.kind === \"string\" ? `\"${display(c)}\"` : display(c),\n  );\n  const more = n.children.length > 5 ? \", …\" : \"\";\n  return n.kind === \"array\" ? `[${parts.join(\", \")}${more}]` : `{ ${parts.join(\", \")}${more} }`;\n}\n\nfunction Marked({ text, query, current }: { text: string; query: string; current: boolean }) {\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={cn(\"rounded-[2px] text-fg\", current ? \"bg-fg text-frame\" : \"bg-fg/15\")}>\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 JsonViewerProps = Omit<React.ComponentProps<\"div\">, \"children\" | \"onCopy\"> & {\n  /** Any JSON-serialisable value: an object, array or primitive. */\n  data: unknown;\n  /** How many levels start open when `defaultExpanded` isn't given. */\n  defaultExpandDepth?: number;\n  /** Open node paths (controlled), e.g. `[\"user\", \"user.roles\"]`. */\n  expanded?: string[];\n  defaultExpanded?: string[];\n  onExpandedChange?: (paths: string[]) => void;\n  /** Shows the search field. `/` focuses it from the tree. */\n  searchable?: boolean;\n  /** Names the tree for screen readers. */\n  label?: string;\n  /** Height before the tree scrolls. */\n  maxHeight?: number | string;\n  /** Arrays longer than this render in pages, with a row to show the rest. */\n  pageSize?: number;\n  /** Shows skeleton rows while the value is fetched. */\n  loading?: boolean;\n  onCopied?: (text: string, what: \"path\" | \"value\") => void;\n};\n\nexport function JsonViewer({\n  data,\n  defaultExpandDepth = 2,\n  expanded: expandedProp,\n  defaultExpanded,\n  onExpandedChange,\n  searchable = true,\n  label = \"JSON\",\n  maxHeight = 340,\n  pageSize = 100,\n  loading = false,\n  onCopied,\n  className,\n  ...rest\n}: JsonViewerProps) {\n  const reduce = useReducedMotion();\n  const uid = useId();\n  const tree = useMemo(() => build(data, null, null), [data]);\n  const index = useMemo(() => {\n    const m = new Map<string, Node>();\n    walk(tree, (n) => m.set(n.id, n));\n    return m;\n  }, [tree]);\n\n  // Controlled or uncontrolled open paths, kept as an array so the prop can be plain data.\n  const [inner, setInner] = useState<string[]>(() => defaultExpanded ?? toDepth(tree, defaultExpandDepth));\n  const openList = expandedProp ?? inner;\n  const open = useMemo(() => new Set(openList), [openList]);\n  const setOpen = (next: Set<string>) => {\n    const list = [...next];\n    if (expandedProp === undefined) setInner(list);\n    onExpandedChange?.(list);\n  };\n\n  const [active, setActive] = useState<string | null>(null);\n  const [wrapped, setWrapped] = useState<Set<string>>(() => new Set());\n  const [limits, setLimits] = useState<Record<string, number>>({});\n  // Toggles from the keyboard land on the same frame; from the pointer they unfold.\n  const [instant, setInstant] = useState(false);\n  const [query, setQuery] = useState(\"\");\n  const [current, setCurrent] = useState(0);\n  const matches = useMemo(() => findMatches(tree, query), [tree, query]);\n  const matchSet = useMemo(() => new Set(matches), [matches]);\n  const currentId = matches.length ? matches[Math.min(current, matches.length - 1)] : null;\n\n  const treeRef = useRef<HTMLUListElement>(null);\n  const searchRef = useRef<HTMLInputElement>(null);\n  const [tip] = useState(() => Tooltip.createHandle<string>());\n\n  const { state: copyState, copy } = useCopy({ timeout: 1400 });\n  const [copied, setCopied] = useState<{ id: string; what: \"path\" | \"value\"; text: string } | null>(null);\n  const doCopy = (n: Node, what: \"path\" | \"value\") => {\n    const text = what === \"path\" ? n.id : n.kind === \"string\" ? (n.value as string) : (JSON.stringify(n.value, null, 2) ?? String(n.value));\n    setCopied({ id: n.id, what, text: n.id || \"root\" });\n    copy(text);\n    onCopied?.(text, what);\n  };\n\n  // The flat, visible order of rows: what arrow keys walk through.\n  const rows = useMemo(() => {\n    const out: Row[] = [];\n    const visit = (n: Node) => {\n      const limit = limits[n.id] ?? pageSize;\n      for (const c of n.children.slice(0, limit)) {\n        out.push({ type: \"node\", node: c });\n        if (isBranch(c) && open.has(c.id)) visit(c);\n      }\n      if (n.children.length > limit) out.push({ type: \"more\", id: `${n.id}#more`, parent: n, shown: limit });\n    };\n    if (isBranch(tree)) visit(tree);\n    else out.push({ type: \"node\", node: tree });\n    return out;\n  }, [tree, open, limits, pageSize]);\n\n  const rowId = (id: string) => `${uid}-${id}`;\n  const activeRow = rows.find((r) => (r.type === \"node\" ? r.node.id : r.id) === active);\n  const activeNode = activeRow?.type === \"node\" ? activeRow.node : null;\n\n  // Keep the active row in view as the keyboard or a search moves it.\n  useEffect(() => {\n    if (active === null) return;\n    const el = treeRef.current?.querySelector<HTMLElement>(`[data-row=\"${CSS.escape(active)}\"]`);\n    el?.scrollIntoView({ block: \"nearest\" });\n  }, [active]);\n\n  const toggle = (n: Node, kb: boolean) => {\n    setInstant(kb);\n    const next = new Set(open);\n    if (next.has(n.id)) next.delete(n.id);\n    else next.add(n.id);\n    setOpen(next);\n  };\n\n  const runSearch = (q: string) => {\n    setQuery(q);\n    setCurrent(0);\n    const found = findMatches(tree, q);\n    if (!found.length) return;\n    // Unfold every match's ancestors so each one is on screen.\n    const next = new Set(open);\n    for (const id of found) for (let p = index.get(id)?.parent; p && p !== tree; p = p.parent) next.add(p.id);\n    setInstant(true);\n    if (next.size !== open.size) setOpen(next);\n    setActive(found[0]);\n  };\n\n  const step = (by: number) => {\n    if (!matches.length) return;\n    const i = (Math.min(current, matches.length - 1) + by + matches.length) % matches.length;\n    setCurrent(i);\n    setActive(matches[i]);\n  };\n\n  const activate = (r: Row, kb: boolean) => {\n    if (r.type === \"more\") {\n      setLimits((l) => ({ ...l, [r.parent.id]: r.shown + pageSize }));\n      return;\n    }\n    const n = r.node;\n    if (isBranch(n) && n.children.length) return toggle(n, kb);\n    // A long value that's cut off unfolds to wrap in place, and folds back.\n    const label = treeRef.current?.querySelector<HTMLElement>(`[data-row=\"${CSS.escape(n.id)}\"] [data-label]`);\n    const cut = label && label.scrollWidth > label.clientWidth + 1;\n    if (cut || wrapped.has(n.id)) {\n      const next = new Set(wrapped);\n      if (next.has(n.id)) next.delete(n.id);\n      else next.add(n.id);\n      setWrapped(next);\n    }\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    if (e.metaKey || e.ctrlKey || e.altKey || !rows.length) return;\n    const i = Math.max(0, rows.findIndex((r) => (r.type === \"node\" ? r.node.id : r.id) === active));\n    const r = rows[i];\n    const idOf = (x: Row) => (x.type === \"node\" ? x.node.id : x.id);\n    const go = (x: Row | undefined) => x && setActive(idOf(x));\n    const n = r.type === \"node\" ? r.node : null;\n    const key = e.key;\n    if (active === null && [\"ArrowDown\", \"ArrowUp\", \"j\", \"k\", \"Home\", \"End\"].includes(key)) {\n      e.preventDefault();\n      return go(key === \"End\" ? rows[rows.length - 1] : rows[0]);\n    }\n    switch (key) {\n      case \"ArrowDown\":\n      case \"j\":\n        e.preventDefault();\n        return go(rows[Math.min(rows.length - 1, i + 1)]);\n      case \"ArrowUp\":\n      case \"k\":\n        e.preventDefault();\n        return go(rows[Math.max(0, i - 1)]);\n      case \"Home\":\n        e.preventDefault();\n        return go(rows[0]);\n      case \"End\":\n        e.preventDefault();\n        return go(rows[rows.length - 1]);\n      case \"ArrowRight\":\n        e.preventDefault();\n        if (n && isBranch(n) && n.children.length) {\n          if (!open.has(n.id)) return toggle(n, true);\n          return go(rows[i + 1]);\n        }\n        return;\n      case \"ArrowLeft\": {\n        e.preventDefault();\n        if (n && isBranch(n) && open.has(n.id)) return toggle(n, true);\n        const parent = r.type === \"more\" ? r.parent : n?.parent;\n        if (parent && parent !== tree) setActive(parent.id);\n        return;\n      }\n      case \"Enter\":\n      case \" \":\n        e.preventDefault();\n        return activate(r, true);\n      case \"*\": {\n        // Open every sibling branch, per the tree pattern.\n        e.preventDefault();\n        const siblings = (n?.parent ?? tree).children.filter((c) => isBranch(c) && c.children.length);\n        setInstant(true);\n        setOpen(new Set([...open, ...siblings.map((c) => c.id)]));\n        return;\n      }\n      case \"c\":\n      case \"C\":\n        if (n) {\n          e.preventDefault();\n          doCopy(n, \"value\");\n        }\n        return;\n      case \"p\":\n      case \"P\":\n        if (n && n.id) {\n          e.preventDefault();\n          doCopy(n, \"path\");\n        }\n        return;\n      case \"/\":\n        if (searchable) {\n          e.preventDefault();\n          searchRef.current?.focus();\n        }\n        return;\n    }\n  };\n\n  const allBranches = useMemo(() => {\n    const out: string[] = [];\n    walk(tree, (n) => n !== tree && isBranch(n) && n.children.length && out.push(n.id));\n    return out;\n  }, [tree]);\n\n  // Folding everything moves the cursor to the top-level row that held it.\n  const topmost = (id: string | null) => {\n    let n = id === null ? undefined : index.get(id.replace(/#more$/, \"\"));\n    while (n?.parent && n.parent !== tree) n = n.parent;\n    return n ? n.id : id;\n  };\n\n  const fold = { duration: instant || reduce ? 0 : 0.22, ease: ease.inOut };\n  // The scope line: the guide under the active branch (or the active row's parent) lights up.\n  const scope = activeNode ? (isBranch(activeNode) && open.has(activeNode.id) ? activeNode.id : activeNode.parent?.id ?? null) : null;\n\n  const renderRow = (n: Node, pos: number, size: number): React.ReactNode => {\n    const branch = isBranch(n) && n.children.length > 0;\n    const isOpen = branch && open.has(n.id);\n    const isActive = active === n.id;\n    const limit = limits[n.id] ?? pageSize;\n    const labelId = `${rowId(n.id)}-label`;\n    const ancestors: string[] = [];\n    for (let p = n.parent; p && p !== tree; p = p.parent) ancestors.unshift(p.id);\n    const wrap = wrapped.has(n.id);\n    const matched = matchSet.has(n.id);\n    const isCurrent = currentId === n.id;\n    const q = matched ? query : \"\";\n\n    return (\n      <li\n        key={n.id}\n        id={rowId(n.id)}\n        role=\"treeitem\"\n        aria-level={n.depth + 1}\n        aria-setsize={size}\n        aria-posinset={pos}\n        aria-expanded={branch ? isOpen : undefined}\n        aria-selected={isActive}\n        aria-labelledby={labelId}\n      >\n        <div\n          data-row={n.id}\n          data-active={isActive || undefined}\n          onClick={() => {\n            setActive(n.id);\n            activate({ type: \"node\", node: n }, false);\n          }}\n          className={cn(\n            // The row's fill lives in --row so the copy actions can fade into exactly the same colour.\n            \"group/row relative flex min-h-6 cursor-default items-center pr-1.5 bg-(--row) [--row:var(--raised)]\",\n            \"hover:[--row:color-mix(in_oklab,var(--fg)_4%,var(--raised))] data-active:[--row:color-mix(in_oklab,var(--fg)_7%,var(--raised))]\",\n            \"group-focus-visible/tree:data-active:outline-1 group-focus-visible/tree:data-active:-outline-offset-1 group-focus-visible/tree:data-active:outline-fg-3 group-focus-visible/tree:data-active:outline-solid\",\n            wrap && \"items-start py-0.5\",\n          )}\n          style={{ paddingLeft: 6 + n.depth * INDENT }}\n        >\n          {ancestors.map((a, d) => (\n            <span\n              key={a}\n              aria-hidden\n              className={cn(\"absolute inset-y-0 w-px transition-colors duration-150\", scope === a ? \"bg-fg-4\" : \"bg-line\")}\n              style={{ left: 6 + d * INDENT + 7.5 }}\n            />\n          ))}\n          <span aria-hidden className={cn(\"grid size-4 shrink-0 place-items-center text-fg-3\", wrap && \"mt-px\")}>\n            {branch && (\n              <ChevronRight\n                size={12}\n                className={cn(\n                  \"transition-transform ease-out-expo motion-reduce:transition-none\",\n                  instant ? \"duration-0\" : \"duration-200\",\n                  isOpen && \"rotate-90\",\n                )}\n              />\n            )}\n          </span>\n          <span\n            id={labelId}\n            data-label\n            className={cn(\"min-w-0 flex-1 pl-1\", wrap ? \"whitespace-pre-wrap pr-12 leading-5 [overflow-wrap:anywhere]\" : \"truncate\")}\n          >\n            {n.key !== null && (\n              <>\n                <span className={typeof n.key === \"number\" ? \"tabular text-fg-3\" : \"text-fg-2\"}>\n                  <Marked text={String(n.key)} query={typeof n.key === \"string\" ? q : \"\"} current={isCurrent} />\n                </span>\n                <span className=\"text-fg-4\">: </span>\n              </>\n            )}\n            <Value n={n} open={isOpen} query={q} current={isCurrent} />\n            {branch && <span className={cn(\"tabular text-fg-4\", !isOpen && \"ml-2\")}>{count(n)}</span>}\n          </span>\n          <span\n            aria-hidden\n            className={cn(\n              // Overlaid, not reserved: long values keep the full width until the row is pointed at.\n              \"absolute flex items-center gap-0.5 pl-5 pr-[5px] opacity-0 pointer-events-none\",\n              \"[background:linear-gradient(to_right,transparent,var(--row)_16px)]\",\n              \"group-hover/row:pointer-events-auto group-hover/row:opacity-100 group-data-active/row:pointer-events-auto group-data-active/row:opacity-100\",\n              wrap ? \"top-0.5 right-px\" : \"inset-y-px right-px\",\n            )}\n          >\n            {n.id && (\n              <RowAction\n                label=\"Copy path\"\n                tip={tip}\n                state={copied?.id === n.id && copied.what === \"path\" ? copyState : \"idle\"}\n                onPress={() => doCopy(n, \"path\")}\n                reduce={!!reduce}\n              >\n                <Link size={13} />\n              </RowAction>\n            )}\n            <RowAction\n              label=\"Copy value\"\n              tip={tip}\n              state={copied?.id === n.id && copied.what === \"value\" ? copyState : \"idle\"}\n              onPress={() => doCopy(n, \"value\")}\n              reduce={!!reduce}\n            >\n              <Copy size={13} />\n            </RowAction>\n          </span>\n        </div>\n        {branch && (\n          <AnimatePresence initial={false}>\n            {isOpen && (\n              <motion.ul\n                role=\"group\"\n                className=\"overflow-hidden\"\n                initial={{ height: 0, opacity: reduce ? 0 : 0.4 }}\n                animate={{ height: \"auto\", opacity: 1, transition: reduce ? { duration: instant ? 0 : 0.12 } : fold }}\n                exit={{ height: 0, opacity: reduce ? 0 : 0.4, transition: reduce ? { duration: instant ? 0 : 0.1 } : { ...fold, duration: fold.duration * 0.7 } }}\n              >\n                {n.children.slice(0, limit).map((c, i) => renderRow(c, i + 1, n.children.length))}\n                {n.children.length > limit && renderMore(n, limit)}\n              </motion.ul>\n            )}\n          </AnimatePresence>\n        )}\n      </li>\n    );\n  };\n\n  const renderMore = (parent: Node, shown: number) => {\n    const id = `${parent.id}#more`;\n    const left = parent.children.length - shown;\n    return (\n      <li key={id} id={rowId(id)} role=\"treeitem\" aria-level={parent.depth + 2} aria-selected={active === id}>\n        <div\n          data-row={id}\n          data-active={active === id || undefined}\n          onClick={() => {\n            setActive(id);\n            activate({ type: \"more\", id, parent, shown }, false);\n          }}\n          className=\"group/row relative flex h-6 cursor-default items-center text-fg-3 hover:bg-fg/[0.04] hover:text-fg data-active:bg-fg/[0.07] data-active:text-fg group-focus-visible/tree:data-active:outline-1 group-focus-visible/tree:data-active:-outline-offset-1 group-focus-visible/tree:data-active:outline-fg-3 group-focus-visible/tree:data-active:outline-solid\"\n          style={{ paddingLeft: 6 + (parent.depth + 1) * INDENT + 20 }}\n        >\n          <span className=\"tabular\">\n            Show {Math.min(left, pageSize)} more\n            <span className=\"text-fg-4\">\n              {\" \"}· {shown} of {parent.children.length} shown\n            </span>\n          </span>\n        </div>\n      </li>\n    );\n  };\n\n  const top = isBranch(tree) ? tree.children : [tree];\n  const topLimit = limits[tree.id] ?? pageSize;\n  const empty = isBranch(tree) && tree.children.length === 0;\n  const status = copyState === \"copied\" && copied ? `Copied ${copied.what} of ${copied.text}` : copyState === \"failed\" ? \"Couldn’t copy. Select the text instead.\" : \"\";\n\n  return (\n    <Tooltip.Provider delay={500}>\n      <div\n        data-slot=\"json-viewer\"\n        data-state={loading ? \"loading\" : \"ready\"}\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        {searchable && (\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              disabled={loading}\n              onChange={(e) => runSearch(e.target.value)}\n              onKeyDown={(e) => {\n                if (e.key === \"Enter\") {\n                  e.preventDefault();\n                  step(e.shiftKey ? -1 : 1);\n                } else if (e.key === \"Escape\" && query) {\n                  e.preventDefault();\n                  runSearch(\"\");\n                } else if (e.key === \"ArrowDown\" && !query) {\n                  e.preventDefault();\n                  treeRef.current?.focus();\n                }\n              }}\n              placeholder=\"Search keys and values\"\n              aria-label={`Search ${label}`}\n              aria-controls={`${uid}-tree`}\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              <>\n                <span className=\"tabular shrink-0 px-1 font-mono text-[11px] text-fg-3\" aria-live=\"polite\">\n                  {matches.length ? `${Math.min(current, matches.length - 1) + 1}/${matches.length}` : \"No matches\"}\n                </span>\n                <ToolButton label=\"Previous match\" tip={tip} onPress={() => step(-1)} disabled={!matches.length}>\n                  <ChevronUp size={14} />\n                </ToolButton>\n                <ToolButton label=\"Next match\" tip={tip} onPress={() => step(1)} disabled={!matches.length}>\n                  <ChevronDown size={14} />\n                </ToolButton>\n                <ToolButton label=\"Clear search\" tip={tip} onPress={() => { runSearch(\"\"); searchRef.current?.focus(); }}>\n                  <X size={14} />\n                </ToolButton>\n                <span aria-hidden className=\"mx-0.5 h-4 w-px bg-line\" />\n              </>\n            )}\n            <ToolButton label=\"Expand all\" tip={tip} disabled={loading || !allBranches.length} onPress={() => { setInstant(false); setOpen(new Set(allBranches)); }}>\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=\"m5 6 3-3 3 3M5 10l3 3 3-3\" />\n              </svg>\n            </ToolButton>\n            <ToolButton label=\"Collapse all\" tip={tip} disabled={loading || !open.size} onPress={() => { setInstant(false); setOpen(new Set()); setActive((a) => topmost(a)); }}>\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=\"m5 2.5 3 3 3-3M5 13.5l3-3 3 3\" />\n              </svg>\n            </ToolButton>\n          </div>\n        )}\n\n        <div className=\"min-h-0 overflow-auto overscroll-contain py-1\" style={{ maxHeight }}>\n          {loading ? (\n            <div aria-hidden className=\"flex flex-col py-0.5\">\n              {[64, 38, 52, 30, 46, 58].map((w, i) => (\n                <div key={i} className=\"flex h-6 items-center gap-2\" style={{ paddingLeft: 26 + (i % 3 === 0 ? 0 : INDENT) }}>\n                  <span className=\"h-2.5 animate-pulse-soft rounded-sm bg-hover\" style={{ width: `${w}%` }} />\n                </div>\n              ))}\n            </div>\n          ) : empty ? (\n            <p className=\"flex h-12 items-center px-3 font-mono text-[12px] text-fg-3\">\n              {tree.kind === \"array\" ? \"[] Empty array\" : \"{} Empty object\"}\n            </p>\n          ) : (\n            <ul\n              ref={treeRef}\n              id={`${uid}-tree`}\n              role=\"tree\"\n              aria-label={label}\n              aria-activedescendant={active !== null && rows.some((r) => (r.type === \"node\" ? r.node.id : r.id) === active) ? rowId(active) : undefined}\n              aria-describedby={`${uid}-keys`}\n              tabIndex={0}\n              onKeyDown={onKeyDown}\n              onFocus={(e) => {\n                // Only a keyboard arrival picks the first row; a click picks its own row.\n                if (e.target === e.currentTarget && e.currentTarget.matches(\":focus-visible\") && active === null && rows[0]) setActive(rows[0].type === \"node\" ? rows[0].node.id : rows[0].id);\n              }}\n              className=\"group/tree font-mono text-[12px] leading-6 outline-none\"\n            >\n              {top.slice(0, topLimit).map((c, i) => renderRow(c, i + 1, top.length))}\n              {top.length > topLimit && renderMore(tree, topLimit)}\n            </ul>\n          )}\n        </div>\n\n        <div className=\"flex h-8 items-center gap-3 border-t border-line px-3 text-[11px]\">\n          <span className=\"min-w-0 flex-1 truncate font-mono text-fg-3\" title={activeNode?.id || undefined}>\n            {activeNode ? (\n              <>\n                <span className=\"text-fg-4\">$</span>\n                {activeNode.id.startsWith(\"[\") ? \"\" : \".\"}\n                {activeNode.id}\n              </>\n            ) : (\n              <span className=\"text-fg-4\">$</span>\n            )}\n          </span>\n          <span id={`${uid}-keys`} className=\"hidden shrink-0 items-center gap-2.5 text-fg-4 pointer-fine:sm:flex\">\n            <span className=\"sr-only\">Arrow keys move and fold. </span>\n            <span className=\"flex items-center gap-1\" aria-hidden>\n              <Kbd>C</Kbd> value\n            </span>\n            <span className=\"sr-only\">Press C to copy the value. </span>\n            <span className=\"flex items-center gap-1\" aria-hidden>\n              <Kbd>P</Kbd> path\n            </span>\n            <span className=\"sr-only\">Press P to copy the path.</span>\n          </span>\n        </div>\n\n        <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n          {status}\n        </span>\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\nfunction Value({ n, open, query, current }: { n: Node; open: boolean; query: string; current: boolean }) {\n  if (n.kind === \"object\" || n.kind === \"array\") {\n    if (!n.children.length) return <span className=\"text-fg-3\">{n.kind === \"array\" ? \"[]\" : \"{}\"}</span>;\n    if (open) return null;\n    return <span className=\"text-fg-3\">{preview(n)}</span>;\n  }\n  const text = display(n);\n  if (n.kind === \"string\")\n    return (\n      <span className=\"text-fg\">\n        <span className=\"text-fg-4\">&quot;</span>\n        <Marked text={text} query={query} current={current} />\n        <span className=\"text-fg-4\">&quot;</span>\n      </span>\n    );\n  if (n.kind === \"null\") return <span className=\"italic text-fg-3\"><Marked text={text} query={query} current={current} /></span>;\n  if (n.kind === \"boolean\") return <span className=\"text-fg-2\"><Marked text={text} query={query} current={current} /></span>;\n  return (\n    <span className={cn(\"text-fg\", n.kind === \"number\" && \"tabular\")}>\n      <Marked text={text} query={query} current={current} />\n    </span>\n  );\n}\n\nfunction Kbd({ children }: { children: React.ReactNode }) {\n  return (\n    <kbd className=\"grid h-4 min-w-4 place-items-center rounded-[4px] border border-line-2 px-1 font-mono text-[10px] leading-none text-fg-3\">\n      {children}\n    </kbd>\n  );\n}\n\ntype Handle = ReturnType<typeof Tooltip.createHandle<string>>;\n\nfunction ToolButton({ label, tip, onPress, disabled, children }: { label: string; tip: Handle; onPress: () => void; disabled?: boolean; children: React.ReactNode }) {\n  return (\n    <Tooltip.Trigger\n      handle={tip}\n      payload={label}\n      disabled={disabled}\n      render={\n        <button\n          type=\"button\"\n          aria-label={label}\n          disabled={disabled}\n          onClick={onPress}\n          className={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            \"disabled:pointer-events-none disabled:opacity-40\",\n            \"before:absolute before:-inset-1.5 before:content-[''] pointer-fine:before:hidden\",\n          )}\n        />\n      }\n    >\n      {children}\n    </Tooltip.Trigger>\n  );\n}\n\nfunction RowAction({\n  label,\n  tip,\n  state,\n  onPress,\n  reduce,\n  children,\n}: {\n  label: string;\n  tip: Handle;\n  state: \"idle\" | \"copied\" | \"failed\";\n  onPress: () => void;\n  reduce: boolean;\n  children: React.ReactNode;\n}) {\n  const draw = reduce ? {} : { initial: { pathLength: 0 }, animate: { pathLength: 1 }, transition: { duration: 0.28, ease: ease.out, delay: 0.04 } };\n  return (\n    <Tooltip.Trigger\n      handle={tip}\n      payload={state === \"copied\" ? \"Copied\" : state === \"failed\" ? \"Couldn’t copy\" : label}\n      closeOnClick={false}\n      render={\n        <button\n          type=\"button\"\n          tabIndex={-1}\n          onClick={(e) => {\n            e.stopPropagation();\n            onPress();\n          }}\n          className={cn(\n            \"relative grid size-5 place-items-center rounded-[5px] outline-none\",\n            \"transition-[background-color,color,scale] duration-150 ease-out hover:bg-fg/[0.07] hover:text-fg active:scale-90 active:duration-75\",\n            \"before:absolute before:-inset-2.5 before:content-[''] pointer-fine:before:hidden\",\n            state === \"failed\" ? \"text-danger\" : state === \"copied\" ? \"text-fg\" : \"text-fg-3\",\n          )}\n        />\n      }\n    >\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        <motion.span\n          key={state}\n          className=\"grid place-items-center\"\n          initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: \"blur(2px)\" }}\n          animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n          exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: \"blur(2px)\" }}\n          transition={reduce ? { duration: 0.12 } : spring.pop}\n        >\n          {state === \"copied\" ? (\n            <svg width={13} height={13} viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.6} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n              <motion.path d=\"M3.5 8.5 6.5 11.5 12.5 4.5\" {...draw} />\n            </svg>\n          ) : state === \"failed\" ? (\n            <X size={13} />\n          ) : (\n            children\n          )}\n        </motion.span>\n      </AnimatePresence>\n    </Tooltip.Trigger>\n  );\n}\n\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/json-viewer"
}
