Components

Getting started

Components109

More

JSON inspectorNew

Read, search and quote a webhook payload or an API response, or see exactly what changed between two versions of a record.

Used forWebhook deliveriesAPI explorersAudit logsAI tool calls

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/json-inspector.json

Where it’s used

  • Webhook deliveries

    Show the payload your endpoint received, and exactly where a broken one broke.

  • API explorers

    Let developers read responses in your dashboard or docs and copy what they need.

  • Audit logs

    Show which fields changed between two versions of a record, and nothing else.

  • AI tool calls

    Inspect the arguments and results of tool calls without scrolling a wall of text.

What it handles for you

Payloads usually land on screen as pretty-printed text: people scroll, count brackets to find which “id” they’re looking at, and retype its path by hand. This turns a payload into something you can read, search and quote.

Instead of wiring upJSON.stringify(data, null, 2)react-json-viewa diff librarya JSONPath buildera JSON error parser
  1. Folds and previews

    Objects and arrays open and close; closed ones show what’s inside, like { id, amount, … } or 12 items.

  2. Searches keys and values

    The branches above every match open, and Enter steps from one match to the next.

  3. Copies the path

    The row you’re on is written out, as data.items[0].id or in jq style, with Copy path and Copy value beside it.

  4. Reads timestamps

    ISO dates and Unix times under keys like created_at say when they are.

  5. Diffs two versions

    Added, removed and changed keys are marked, arrays are matched by id, and unchanged keys fold away.

  6. Explains broken JSON

    It points at the line and column where parsing stopped, with the lines around it.

Overview

Payloads usually land on screen as a wall of pretty-printed text: you scroll, you squint, you count brackets to find which "id" you're looking at, and you retype its path by hand. Here objects and arrays fold open by height, and closed ones show what's inside ({ id, amount, … } or 2 items). Search finds keys and values at once; the branches above every match unfold, the count rolls, and Enter steps from match to match and scrolls each into view. The row you're on shows its whole string, says when a timestamp is, and its path is written out underneath (data.object.lines.data[0].amount), morphing letter by letter as you move, with Copy path and Copy value beside it. Give it the value before and it becomes a diff: added keys are marked, removed ones struck through, changed values sit struck out beside the new, and everything that didn't change folds into "12 unchanged". Text that isn't JSON points at the line and column where it broke.

States

fold
A container opens and closes by height (grid rows, 280ms) while its chevron turns 90°. Children stay mounted once shown, so closing animates as well as opening. Closed containers preview their first three keys or their item count.
focus
The row you're on (clicked or reached with the arrows) takes the accent fill, shows its whole string instead of a truncated one, and adds a readable date beside ISO strings and Unix timestamps under keys like created or period_end.
path
The footer writes the path of that row and morphs it letter by letter as you move (360ms), so the shared prefix stays put. Copy path and Copy value are Status buttons: the label becomes Copied and a check draws itself.
search
Matches are marked in keys and values 120ms after typing stops. Branches above every match open; the "3/12" count opens in beside the field and rolls; Enter, ↓ or the arrows step to the next match, which is outlined and scrolled to a third of the way down.
diff
With compare, each row is same, added (+, emerald tint), removed (−, red tint, struck) or changed (~, old value struck beside the new). Containers with changes open; closed ones show their counts. Unchanged keys fold into a "12 unchanged" row until you ask for them or a search finds one.
broken
Text that doesn't parse shows the reason, the line and column, and the lines around it with a caret under the exact character, found by its own scanner because browsers report positions differently.

Usage

usage.tsx
import { JsonInspector } from "@/components/json-inspector";<JsonInspector title="invoice.paid" data={event} />

Works with

Webhook deliveries and an audit log

Show the body your endpoint received as text, so a malformed delivery says where it broke. For an audit log, pass the record before and after a change; match array items by their id so an inserted item doesn't mark every row below it as changed.

example.tsx
// app/webhooks/[id]/delivery.tsximport { JsonInspector } from "@/components/json-inspector";export async function Delivery({ id }: { id: string }) {  const delivery = await db.webhookDelivery.findUniqueOrThrow({ where: { id } });  return (    <JsonInspector      label="Request body"      title={`${delivery.event} · ${delivery.status} in ${delivery.ms} ms`}      text={delivery.body}      pathStyle="jq"    />  );}// app/audit/[id]/change.tsxexport async function Change({ id }: { id: string }) {  const event = await db.auditEvent.findUniqueOrThrow({ where: { id }, include: { actor: true } });  return (    <JsonInspector      label="Change"      title={`${event.actor.name} ${event.action}`}      compare={event.before}      data={event.after}      arrayKey="id"    />  );}

Props

PropTypeDescription
dataunknownA value to show. Circular references show as "[circular]".
textstringJSON text instead of a value; if it doesn't parse, the error is shown with the line and column.
compareunknownThe value before. With it, the inspector shows how compare became data.
arrayKeystringIn a diff, match array items by this key ("id") instead of by position.
expandDepthnumberdefault 2Levels open at first (in a diff, everything with changes opens instead).
titleReactNodeShown at the top left, e.g. the event name and status.
defaultQuerystringStart with this search, e.g. from a "find in payload" link.
pathStyle"js" | "jq"default "js"How paths are written: data.items[0].id, or .data.items[0].id for jq.
maxHeightnumberdefault 360Height of the scrolling body in px.
labelstringdefault "JSON"Accessible name of the tree.

Accessibility and motion

  • A real tree for assistive tech: treeitems with level, position and expanded state, one tab stop. ↑ and ↓ move, → opens or steps in, ← closes or steps out, Home and End jump, Enter or Space toggles, / jumps to search, and ⌘C or Ctrl+C copies the value of the row you're on.
  • Each row has a spoken label (key, value, and in a diff "added", "removed" or "was …"); match counts and copies are announced in a status region.
  • Containers with more than 100 children show them a hundred at a time with "Show 100 more · 1,240 left"; a search reaches past that to show a match.
  • json-tree.ts has no React in it: buildTree, diffTree, formatPath, searchTree and parseError work on a server too, e.g. to store the diff of an audit event.
  • Strings, numbers and booleans use the same light and dark palette as Code block; diff marks use emerald, red and amber with a sign beside each, so colour is never the only signal.
  • Reduced motion: folds, the chevron and the path change in place.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

The CLI also installs Text morph, Number roll, Status button.

Source

components/json-inspector.tsx
"use client";import * as React from "react";import { buildTree, diffTree, flatten, formatPath, parseError, searchTree, type JsonKey, type JsonNode } from "./json-tree";import { NumberRoll } from "./number-roll";import { StatusButton, type ActionStatus } from "./status-button";import { TextMorph } from "./text-morph";/* ───────────────────────────────────────────────────────── * JSON INSPECTOR: a payload you can read, search and quote * *   fold      objects and arrays open and close by height, the *             chevron turns; closed ones show what's inside *             ({ id, amount, … } or 12 items) *   search    every key and value that matches; the branches *             above them unfold, the count rolls, Enter steps *             from match to match and scrolls to it *   path      the row you're on is written out below, morphing *             letter by letter as you move (data.lines[0].id), *             ready to copy along with its value *   read      the row you're on shows its whole string, and *             timestamps say when they are *   diff      give it the value before: added, removed and *             changed keys are marked, old values sit struck *             through beside the new, and what didn't change *             folds into "12 unchanged" *   broken    text that isn't JSON points at the line and column * * It's a tree for the keyboard: arrows move and open, Home and * End jump, / searches, ⌘C copies the value you're on. * ───────────────────────────────────────────────────────── */export interface JsonInspectorProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {  /** A value to show. */  data?: unknown;  /** Or JSON text; if it doesn't parse, the error is shown where it is. */  text?: string;  /** The value before: shows how it became `data`. */  compare?: unknown;  /** In a diff, match array items by this key ("id") instead of by position. */  arrayKey?: string;  /** Levels open at first. */  expandDepth?: number;  /** Shown at the top left, e.g. the event name. */  title?: React.ReactNode;  /** Height of the scrolling body in px. */  maxHeight?: number;  /** How paths are written when copied. */  pathStyle?: "js" | "jq";  /** Accessible name of the tree. */  label?: string;  /** Start with this search, e.g. from a "find in payload" link. */  defaultQuery?: string;}const EASE = "cubic-bezier(0.16,1,0.3,1)";const CHUNK = 100;const INDENT = 14;const FOCUS = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40";// GitHub's palette for light and dark, as in Code block.const TONE = {  string: "text-[#0a3069] dark:text-[#a5d6ff]",  number: "text-[#0550ae] dark:text-[#79c0ff]",  boolean: "text-[#cf222e] dark:text-[#ff7b72]",  null: "italic text-muted-foreground",};const reducedQuery = "(prefers-reduced-motion: reduce)";const subscribeReduced = (onChange: () => void) => {  const query = window.matchMedia(reducedQuery);  query.addEventListener("change", onChange);  return () => query.removeEventListener("change", onChange);};const useReducedMotion = () =>  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);const isContainer = (n: JsonNode) => n.children !== undefined;const removedIndex = (k: JsonKey) => (typeof k === "string" && /^\d+ \(removed\)$/.test(k) ? parseInt(k) : k);function valueAt(value: unknown, path: JsonKey[]) {  let v = value;  for (const k of path) {    if (v === null || typeof v !== "object") return undefined;    v = (v as Record<string | number, unknown>)[removedIndex(k)];  }  return v;}const TIME_KEY = /(^|_)(at|created|updated|date|time|timestamp|period_start|period_end|expires|due)($|_)/i;/** When a value is a moment: an ISO string, or Unix seconds under a key that sounds like a time. */function when(node: JsonNode): string | null {  const v = node.value;  let date: Date | null = null;  if (typeof v === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(v)) date = new Date(v);  else if (typeof v === "number" && typeof node.key === "string" && TIME_KEY.test(node.key) && v > 9e8 && v < 4e9) date = new Date(v * 1000);  else if (typeof v === "number" && typeof node.key === "string" && TIME_KEY.test(node.key) && v > 9e11 && v < 4e12) date = new Date(v);  if (!date || isNaN(date.getTime())) return null;  return new Intl.DateTimeFormat(undefined, { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" }).format(date);}function Marked({ text, query, current }: { text: string; query: string; current: boolean }) {  const q = query.trim().toLowerCase();  if (!q) return <>{text}</>;  const lower = text.toLowerCase();  const parts: React.ReactNode[] = [];  let at = 0;  for (let i = lower.indexOf(q); i !== -1; i = lower.indexOf(q, i + q.length)) {    if (i > at) parts.push(text.slice(at, i));    parts.push(      <mark key={i} className={`rounded-[3px] text-inherit ${current ? "bg-primary/30 shadow-[0_0_0_1px_color-mix(in_oklab,var(--primary)_55%,transparent)]" : "bg-primary/15"}`}>        {text.slice(i, i + q.length)}      </mark>,    );    at = i + q.length;  }  if (at < text.length) parts.push(text.slice(at));  return <>{parts}</>;}function Value({ node, query, current, before }: { node: JsonNode; query: string; current: boolean; before?: boolean }) {  const v = before ? node.before : node.value;  const kind = before ? (node.beforeKind ?? node.kind) : node.kind;  if (kind === "object" || kind === "array") return <span className="text-muted-foreground">{String(v)}</span>;  if (kind === "string" && !before) return <span className={TONE.string}>“<Marked text={String(v)} query={query} current={current} />”</span>;  if (kind === "string") return <span className={TONE.string}>“{String(v)}”</span>;  const tone = kind === "null" ? TONE.null : kind === "boolean" ? TONE.boolean : TONE.number;  return <span className={tone}>{before ? String(v) : <Marked text={String(v)} query={query} current={current} />}</span>;}function preview(node: JsonNode) {  if (node.kind === "array") return `${node.size} ${node.size === 1 ? "item" : "items"}`;  const keys = (node.children ?? []).slice(0, 3).map((c) => String(c.key));  return `{ ${keys.join(", ")}${(node.size ?? 0) > 3 ? ", …" : ""} }`;}function Chevron({ open, reduced }: { open: boolean; reduced: boolean }) {  return (    <svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="size-3 text-muted-foreground" style={{ transform: open ? "rotate(90deg)" : "none", transition: reduced ? "none" : `transform 260ms ${EASE}` }}>      <path d="M6.25 4.5 9.75 8l-3.5 3.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />    </svg>  );}/** A block that opens and closes by height; its children stay mounted once shown, so closing animates too. */function Fold({ open, reduced, children, id }: { open: boolean; reduced: boolean; children: React.ReactNode; id?: string }) {  return (    <div      id={id}      role="group"      className="grid"      style={{ gridTemplateRows: open ? "1fr" : "0fr", opacity: open ? 1 : 0, transition: reduced ? "none" : `grid-template-rows 280ms ${EASE}, opacity ${open ? 240 : 140}ms ${EASE}` }}      inert={!open || undefined}    >      <div className="min-h-0 overflow-hidden">{children}</div>    </div>  );}export function JsonInspector({  data,  text,  compare,  arrayKey,  expandDepth = 2,  title,  maxHeight = 360,  pathStyle = "js",  label = "JSON",  defaultQuery = "",  className = "",  ...props}: JsonInspectorProps) {  const uid = React.useId();  const reduced = useReducedMotion();  const diff = compare !== undefined;  // The value: given, or parsed from text.  const parsed = React.useMemo(() => {    if (text === undefined) return { ok: true as const, value: data };    try {      return { ok: true as const, value: JSON.parse(text) as unknown };    } catch (err) {      return { ok: false as const, error: parseError(text, err) };    }  }, [text, data]);  const value = parsed.ok ? parsed.value : undefined;  const root = React.useMemo(() => (diff ? diffTree(compare, value, { arrayKey }) : buildTree(value)), [diff, compare, value, arrayKey]);  const { all, parent } = React.useMemo(() => flatten(root), [root]);  const byId = React.useMemo(() => new Map(all.map((n) => [n.id, n])), [all]);  const initialOpen = React.useCallback(() => {    const open = new Set<string>();    for (const n of all) if (isContainer(n) && (diff ? n.change === "changed" && n.children?.length : n.depth < expandDepth)) open.add(n.id);    open.add(root.id);    return open;  }, [all, diff, expandDepth, root.id]);  const [expanded, setExpanded] = React.useState(initialOpen);  const [seenRoot, setSeenRoot] = React.useState(root);  const [focusId, setFocusId] = React.useState(root.id);  const [query, setQuery] = React.useState(defaultQuery);  const [typed, setTyped] = React.useState(defaultQuery);  const [matchIndex, setMatchIndex] = React.useState(0);  const [onlyChanges, setOnlyChanges] = React.useState(true);  const [showSame, setShowSame] = React.useState<Set<string>>(() => new Set());  const [chunks, setChunks] = React.useState<Map<string, number>>(() => new Map());  const [copied, setCopied] = React.useState<{ path: ActionStatus; value: ActionStatus }>({ path: "idle", value: "idle" });  const [announce, setAnnounce] = React.useState("");  const [keyed, setKeyed] = React.useState(false);  const mountedRef = React.useRef<Set<string>>(new Set());  const scrollerRef = React.useRef<HTMLDivElement>(null);  const searchRef = React.useRef<HTMLInputElement>(null);  const rowRefs = React.useRef(new Map<string, HTMLDivElement>());  // New data: start again from its own first view.  if (root !== seenRoot) {    setSeenRoot(root);    setExpanded(initialOpen());    setFocusId(root.id);    setShowSame(new Set());    setChunks(new Map());  }  // Typing settles before it searches.  React.useEffect(() => {    const t = window.setTimeout(() => setQuery(typed), 120);    return () => window.clearTimeout(t);  }, [typed]);  const matches = React.useMemo(() => searchTree(all, query), [all, query]);  const current = matches.length ? matches[Math.min(matchIndex, matches.length - 1)] : null;  // The branches above every match open while you search.  const forced = React.useMemo(() => {    const out = new Set<string>();    for (const id of matches) for (let p = parent.get(id); p; p = parent.get(p)) out.add(p);    return out;  }, [matches, parent]);  const isOpen = (id: string) => expanded.has(id) || forced.has(id);  // A match that sits past a "show more" or among hidden unchanged keys is shown too.  const matchSet = React.useMemo(() => new Set(matches), [matches]);  const hiddenSame = (n: JsonNode, p: JsonNode) => diff && onlyChanges && n.change === "same" && !showSame.has(p.id) && !forced.has(n.id) && !matchSet.has(n.id);  const shownChildren = (n: JsonNode) => {    const kids = (n.children ?? []).filter((c) => !hiddenSame(c, n));    const limit = chunks.get(n.id) ?? CHUNK;    const needed = kids.findIndex((c, i) => i >= limit && (c.id === current || forced.has(c.id)));    return kids.slice(0, Math.max(limit, needed + 1));  };  /** Where a row's key starts, so brackets and "show more" line up with it. */  const keyStart = (depth: number) => 6 + depth * INDENT + (diff ? 12 : 0) + 18;  // Rows in the order the keyboard walks them.  const order = React.useMemo(() => {    const out: string[] = [];    const walk = (n: JsonNode) => {      out.push(n.id);      if (isContainer(n) && isOpen(n.id)) shownChildren(n).forEach(walk);    };    walk(root);    return out;    // eslint-disable-next-line react-hooks/exhaustive-deps  }, [root, expanded, forced, onlyChanges, showSame, chunks, current]);  const toggle = (id: string, open?: boolean) => {    setExpanded((s) => {      const next = new Set(s);      if (open ?? !next.has(id)) next.add(id);      else next.delete(id);      return next;    });  };  const move = (id: string, scroll = true) => {    setFocusId(id);    const row = rowRefs.current.get(id);    row?.focus({ preventScroll: !scroll });  };  // Stepping through matches puts you on each one and brings it into view.  React.useEffect(() => {    if (!current) return;    setFocusId(current);    const frame = requestAnimationFrame(() => {      const row = rowRefs.current.get(current);      const scroller = scrollerRef.current;      if (!row || !scroller) return;      const top = row.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scroller.scrollTop;      if (top < scroller.scrollTop + 8 || top + row.offsetHeight > scroller.scrollTop + scroller.clientHeight - 8)        scroller.scrollTo({ top: Math.max(0, top - scroller.clientHeight / 3), behavior: reduced ? "auto" : "smooth" });    });    return () => cancelAnimationFrame(frame);  }, [current, reduced]);  React.useEffect(() => {    if (!query) return;    const t = window.setTimeout(() => setAnnounce(matches.length ? `${matches.length} ${matches.length === 1 ? "match" : "matches"}` : "No matches"), 300);    return () => window.clearTimeout(t);  }, [matches.length, query]);  const step = (dir: 1 | -1) => {    if (!matches.length) return;    setMatchIndex((i) => (Math.min(i, matches.length - 1) + dir + matches.length) % matches.length);  };  const focused = byId.get(focusId) ?? root;  const focusPath = formatPath(focused.path, pathStyle);  const focusValue = () => {    const from = focused.change === "removed" ? compare : value;    const v = focused.path.length ? valueAt(from, focused.path) : from;    return typeof v === "string" ? v : JSON.stringify(v, null, 2);  };  const copy = async (what: "path" | "value") => {    try {      await navigator.clipboard.writeText(what === "path" ? focusPath : (focusValue() ?? ""));      setCopied((c) => ({ ...c, [what]: "success" }));      setAnnounce(what === "path" ? `Copied ${focusPath}` : "Copied the value");    } catch {      setCopied((c) => ({ ...c, [what]: "error" }));    }  };  const onTreeKey = (e: React.KeyboardEvent) => {    setKeyed(true);    const at = order.indexOf(focusId);    const n = focused;    if (e.key === "ArrowDown" && at < order.length - 1) move(order[at + 1]);    else if (e.key === "ArrowUp" && at > 0) move(order[at - 1]);    else if (e.key === "Home") move(order[0]);    else if (e.key === "End") move(order[order.length - 1]);    else if (e.key === "ArrowRight" && isContainer(n)) {      if (!isOpen(n.id)) toggle(n.id, true);      else if (order[at + 1] && parent.get(order[at + 1]) === n.id) move(order[at + 1]);    } else if (e.key === "ArrowLeft") {      if (isContainer(n) && isOpen(n.id) && !forced.has(n.id)) toggle(n.id, false);      else if (parent.get(n.id)) move(parent.get(n.id)!);    } else if ((e.key === "Enter" || e.key === " ") && isContainer(n)) toggle(n.id);    else if (e.key === "/" ) {      searchRef.current?.focus();      searchRef.current?.select();    } else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "c" && !window.getSelection()?.toString()) {      void copy("value");    } else return;    e.preventDefault();  };  const allOpen = all.every((n) => !isContainer(n) || expanded.has(n.id));  const counts = root.counts ?? { added: 0, removed: 0, changed: 0 };  /* ── Rows ── */  const renderNode = (n: JsonNode, p: JsonNode | null, index: number, siblings: number): React.ReactNode => {    const container = isContainer(n);    const open = container && isOpen(n.id);    if (open) mountedRef.current.add(n.id);    const isFocus = n.id === focusId;    const isMatch = n.id === current;    const keyText = n.key === null ? null : String(removedIndex(n.key));    const kids = container && (open || mountedRef.current.has(n.id)) ? shownChildren(n) : [];    const sameHidden = container && diff && onlyChanges && !showSame.has(n.id) ? (n.children ?? []).filter((c) => hiddenSame(c, n)).length : 0;    const total = container ? (n.children ?? []).length - sameHidden : 0;    const limit = chunks.get(n.id) ?? CHUNK;    const moment = isFocus && !container ? when(n) : null;    const removed = n.change === "removed";    const sign = !diff ? null : n.change === "added" ? "+" : removed ? "−" : n.change === "changed" && !container ? "~" : "";    const tint = n.change === "added" ? "bg-emerald-500/[0.07]" : removed ? "bg-red-500/[0.07]" : "";    return (      <React.Fragment key={n.id}>        <div          ref={(el) => {            if (el) rowRefs.current.set(n.id, el);            else rowRefs.current.delete(n.id);          }}          role="treeitem"          data-id={n.id}          aria-level={n.depth + 1}          aria-setsize={siblings}          aria-posinset={index + 1}          aria-expanded={container ? open : undefined}          aria-selected={isFocus}          aria-owns={container && open ? `${uid}-g${n.id}` : undefined}          aria-label={`${keyText ?? label}${container ? `, ${n.kind === "array" ? `${n.size} items` : `${n.size} keys`}` : `: ${String(n.value)}`}${removed ? ", removed" : n.change === "added" ? ", added" : !container && n.change === "changed" ? `, was ${String(n.before)}` : ""}`}          tabIndex={isFocus ? 0 : -1}          onClick={() => {            setKeyed(false);            setFocusId(n.id);            if (container) toggle(n.id);          }}          onFocus={() => setFocusId(n.id)}          className={`relative flex min-h-[24px] cursor-pointer items-start rounded-[7px] pr-2 font-mono text-[12px] leading-[24px] outline-none transition-colors duration-150 ${tint} ${            isFocus ? (keyed ? "bg-accent shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--ring)_45%,transparent)]" : "bg-accent") : "hover:bg-accent/60"          }`}          style={{ paddingLeft: 6 + n.depth * INDENT }}        >          {/* Hairline guides for each level above this row. */}          {Array.from({ length: n.depth }, (_, d) => (            <span key={d} aria-hidden="true" className="pointer-events-none absolute inset-y-0 w-px bg-border" style={{ left: 6 + d * INDENT + (diff ? 12 : 0) + 7 }} />          ))}          {diff && (            <span aria-hidden="true" className={`w-3 shrink-0 text-center ${n.change === "added" ? "text-emerald-600 dark:text-emerald-400" : removed ? "text-red-600 dark:text-red-400" : "text-amber-600 dark:text-amber-400"}`}>              {sign}            </span>          )}          <span aria-hidden="true" className="flex h-6 w-3.5 shrink-0 items-center justify-center">            {container && <Chevron open={open} reduced={reduced} />}          </span>          <span className={`flex min-w-0 flex-1 gap-x-1.5 pl-1 ${isFocus && !container ? "flex-wrap" : ""}`}>            {keyText !== null && (              <span className={`shrink-0 ${typeof removedIndex(n.key!) === "number" ? "text-muted-foreground" : "text-foreground"} ${removed ? "line-through decoration-red-500/50" : ""}`}>                {typeof n.key === "string" && !/^\d+ \(removed\)$/.test(n.key) ? <Marked text={keyText} query={query} current={isMatch} /> : keyText}                <span className="text-muted-foreground">:</span>              </span>            )}            {container ? (              <span className="min-w-0 truncate text-muted-foreground">                {open ? (n.kind === "array" ? "[" : "{") : preview(n)}                {diff && !open && n.change === "changed" && (                  <span className="ml-1.5 text-[11px]">                    {n.counts && [n.counts.added ? `+${n.counts.added}` : "", n.counts.removed ? `−${n.counts.removed}` : "", n.counts.changed ? `~${n.counts.changed}` : ""].filter(Boolean).join(" ")}                  </span>                )}              </span>            ) : (              <span className={`min-w-0 ${isFocus ? "whitespace-pre-wrap break-all" : "truncate"} ${removed ? "line-through decoration-red-500/50" : ""}`}>                {n.change === "changed" && (                  <>                    <span className="line-through decoration-red-500/60 opacity-70">                      <Value node={n} query={query} current={isMatch} before />                    </span>                    <span className="px-1.5 text-muted-foreground">→</span>                  </>                )}                <Value node={n} query={query} current={isMatch} />              </span>            )}            {moment && <span className="shrink-0 font-sans text-[11px] text-muted-foreground animate-[ui-fade-in_300ms_ease-out_both]">{moment}</span>}          </span>        </div>        {container && (          <Fold open={open} reduced={reduced} id={`${uid}-g${n.id}`}>            {kids.map((c, i) => renderNode(c, n, i, kids.length))}            {kids.length < total && (              <button                type="button"                tabIndex={-1}                onClick={() => setChunks((m) => new Map(m).set(n.id, limit + CHUNK))}                className="flex h-6 w-full cursor-pointer items-center rounded-[7px] text-left font-sans text-[11.5px] text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"                style={{ paddingLeft: keyStart(n.depth + 1) }}              >                Show {Math.min(CHUNK, total - kids.length)} more · {(total - kids.length).toLocaleString()} left              </button>            )}            {sameHidden > 0 && (              <button                type="button"                tabIndex={-1}                onClick={() => setShowSame((s) => new Set(s).add(n.id))}                className="flex h-6 w-full cursor-pointer items-center rounded-[7px] text-left font-sans text-[11.5px] text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"                style={{ paddingLeft: keyStart(n.depth + 1) }}              >                {sameHidden} unchanged              </button>            )}            <div aria-hidden="true" className="font-mono text-[12px] leading-[22px] text-muted-foreground" style={{ paddingLeft: keyStart(n.depth) }}>              {n.kind === "array" ? "]" : "}"}            </div>          </Fold>        )}      </React.Fragment>    );  };  /* ── Parse error ── */  if (!parsed.ok) {    const { message, line, column } = parsed.error;    const lines = (text ?? "").split("\n");    const from = Math.max(1, line - 2);    const to = Math.min(lines.length, line + 2);    return (      <div className={`overflow-hidden rounded-[18px] border border-border bg-background text-foreground ${className}`} {...props}>        <div className="flex items-center gap-2 border-b border-border px-3.5 py-2.5 text-[12.5px] text-red-600 dark:text-red-400" role="alert">          <svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="size-3.5 shrink-0">            <circle cx="8" cy="8" r="6.25" stroke="currentColor" strokeWidth="1.5" />            <path d="M8 4.75v3.75" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />            <circle cx="8" cy="11" r="1" fill="currentColor" />          </svg>          <span className="min-w-0">            Not valid JSON: {message}. Line {line}, column {column}.          </span>        </div>        <pre className="overflow-x-auto py-2 font-mono text-[12px] leading-[22px]">          {lines.slice(from - 1, to).map((l, i) => {            const n = from + i;            const bad = n === line;            return (              <React.Fragment key={n}>                <div className={`flex ${bad ? "bg-red-500/[0.07]" : ""}`}>                  <span className="w-10 shrink-0 select-none pr-3 text-right text-muted-foreground">{n}</span>                  <span className="whitespace-pre pr-4">{l || " "}</span>                </div>                {bad && (                  <div className="flex text-red-600 dark:text-red-400" aria-hidden="true">                    <span className="w-10 shrink-0" />                    <span className="whitespace-pre">{" ".repeat(Math.max(0, column - 1))}^</span>                  </div>                )}              </React.Fragment>            );          })}        </pre>      </div>    );  }  /* ── The inspector ── */  return (    <div className={`@container overflow-hidden rounded-[18px] border border-border bg-background text-foreground ${className}`} {...props}>      <div className="flex flex-wrap items-center gap-x-2 gap-y-2 border-b border-border px-3 py-2">        {/* The title keeps room to be read; the search wraps onto its own line before it truncates. */}        {title && <div className="min-w-[min(100%,13rem)] flex-1 truncate text-[12.5px] font-medium">{title}</div>}        {diff && (          <div className="flex shrink-0 items-center gap-2 text-[11.5px] tabular-nums">            {counts.changed > 0 && <span className="text-amber-700 dark:text-amber-400">{counts.changed} changed</span>}            {counts.added > 0 && <span className="text-emerald-700 dark:text-emerald-400">{counts.added} added</span>}            {counts.removed > 0 && <span className="text-red-600 dark:text-red-400">{counts.removed} removed</span>}            {!counts.changed && !counts.added && !counts.removed && <span className="text-muted-foreground">No changes</span>}          </div>        )}        <div className="ml-auto flex min-w-0 items-center gap-1 @max-[460px]:w-full">          <label className="relative flex h-7 min-w-0 flex-1 items-center gap-1.5 rounded-full px-2.5 shadow-[inset_0_0_0_1px_var(--border)] transition-shadow focus-within:shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--ring)_55%,var(--border))] @min-[460px]:w-[190px] @min-[460px]:flex-none">            <svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="size-3.5 shrink-0 text-muted-foreground">              <circle cx="7" cy="7" r="4.25" stroke="currentColor" strokeWidth="1.5" />              <path d="m10.25 10.25 3 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />            </svg>            <input              ref={searchRef}              value={typed}              onChange={(e) => {                setTyped(e.target.value);                setMatchIndex(0);              }}              onKeyDown={(e) => {                if (e.key === "Enter") {                  e.preventDefault();                  step(e.shiftKey ? -1 : 1);                } else if (e.key === "ArrowDown") {                  e.preventDefault();                  step(1);                } else if (e.key === "ArrowUp") {                  e.preventDefault();                  step(-1);                } else if (e.key === "Escape" && typed) {                  e.preventDefault();                  e.stopPropagation();                  setTyped("");                }              }}              placeholder="Search keys and values"              aria-label={`Search ${label}`}              aria-controls={`${uid}-tree`}              className="h-full min-w-0 flex-1 bg-transparent text-[12.5px] outline-none placeholder:text-muted-foreground [@media(pointer:coarse)]:text-[16px]"            />            {/* The count and the arrows open in once there's something to step through. */}            <span              className="grid shrink-0"              style={{ gridTemplateColumns: query ? "1fr" : "0fr", opacity: query ? 1 : 0, transition: reduced ? "none" : `grid-template-columns 300ms ${EASE}, opacity 200ms ${EASE}` }}            >              <span className="flex min-w-0 items-center overflow-hidden [clip-path:inset(-4px)]">                <span className={`whitespace-nowrap pl-1 text-[11.5px] tabular-nums ${matches.length ? "text-muted-foreground" : "text-red-600 dark:text-red-400"}`}>                  {matches.length ? (                    <>                      <NumberRoll value={Math.min(matchIndex, matches.length - 1) + 1} duration={500} />/<NumberRoll value={matches.length} duration={500} />                    </>                  ) : (                    "0"                  )}                </span>                <button type="button" tabIndex={query ? 0 : -1} aria-label="Previous match" onClick={() => step(-1)} className={`ml-1 flex size-5 cursor-pointer items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-foreground ${FOCUS}`}>                  <svg viewBox="0 0 16 16" fill="none" className="size-3" aria-hidden="true">                    <path d="M4.5 9.75 8 6.25l3.5 3.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />                  </svg>                </button>                <button type="button" tabIndex={query ? 0 : -1} aria-label="Next match" onClick={() => step(1)} className={`flex size-5 cursor-pointer items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-foreground ${FOCUS}`}>                  <svg viewBox="0 0 16 16" fill="none" className="size-3" aria-hidden="true">                    <path d="M4.5 6.25 8 9.75l3.5-3.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />                  </svg>                </button>              </span>            </span>          </label>          {diff ? (            <button              type="button"              aria-pressed={!onlyChanges}              onClick={() => setOnlyChanges((v) => !v)}              className={`h-7 shrink-0 cursor-pointer rounded-full px-2.5 text-[12px] font-medium text-muted-foreground shadow-[inset_0_0_0_1px_var(--border)] transition-colors hover:bg-accent hover:text-foreground ${FOCUS}`}            >              <TextMorph>{onlyChanges ? "Show all" : "Only changes"}</TextMorph>            </button>          ) : (            <button              type="button"              aria-label={allOpen ? "Collapse all" : "Expand all"}              onClick={() => {                if (allOpen) setExpanded(new Set([root.id]));                else setExpanded(new Set(all.filter((n) => isContainer(n)).slice(0, 3000).map((n) => n.id)));                setFocusId(root.id);              }}              className={`flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground shadow-[inset_0_0_0_1px_var(--border)] transition-colors hover:bg-accent hover:text-foreground ${FOCUS}`}            >              <svg viewBox="0 0 16 16" fill="none" className="size-3.5" aria-hidden="true">                <path                  d="M5 6.5 8 3.5l3 3"                  stroke="currentColor"                  strokeWidth="1.5"                  strokeLinecap="round"                  strokeLinejoin="round"                  style={{ transform: allOpen ? "translateY(2.5px) rotate(180deg)" : "none", transformOrigin: "8px 5px", transition: reduced ? "none" : `transform 320ms ${EASE}` }}                />                <path                  d="M5 9.5 8 12.5l3-3"                  stroke="currentColor"                  strokeWidth="1.5"                  strokeLinecap="round"                  strokeLinejoin="round"                  style={{ transform: allOpen ? "translateY(-2.5px) rotate(180deg)" : "none", transformOrigin: "8px 11px", transition: reduced ? "none" : `transform 320ms ${EASE}` }}                />              </svg>            </button>          )}        </div>      </div>      <div ref={scrollerRef} className="overflow-auto overscroll-contain p-1.5" style={{ maxHeight }}>        <div id={`${uid}-tree`} role="tree" aria-label={label} onKeyDown={onTreeKey} className="min-w-0">          {renderNode(root, null, 0, 1)}        </div>      </div>      <div className="flex h-10 items-center gap-1 border-t border-border pl-3.5 pr-1.5">        <code className="flex min-w-0 flex-1 justify-end overflow-hidden whitespace-nowrap font-mono text-[11.5px] text-muted-foreground [mask-image:linear-gradient(to_right,transparent,black_16px)]">          <span className="ml-auto shrink-0">            <TextMorph duration={360}>{focusPath}</TextMorph>          </span>        </code>        <StatusButton variant="ghost" size="sm" status={copied.path} labels={{ idle: "Copy path", success: "Copied", error: "Couldn’t copy" }} onClick={() => void copy("path")} onReset={() => setCopied((c) => ({ ...c, path: "idle" }))} resetAfter={1400} />        <StatusButton variant="ghost" size="sm" status={copied.value} labels={{ idle: "Copy value", success: "Copied", error: "Couldn’t copy" }} onClick={() => void copy("value")} onReset={() => setCopied((c) => ({ ...c, value: "idle" }))} resetAfter={1400} />      </div>      <span className="sr-only" role="status" aria-live="polite">        {announce}      </span>    </div>  );}
components/json-tree.ts
/* ───────────────────────────────────────────────────────── * JSON TREE: the model under the JSON inspector * *   build    any value into nodes with stable ids (their paths) *   diff     two values into one tree: each node same, added, *            removed or changed, with counts on the way up; *            arrays of records can be matched by a key ("id") *   paths    data.items[2].id, or .data.items[2].id for jq *   search   keys and values that contain the query *   errors   where JSON.parse gave up, as a line and column * ───────────────────────────────────────────────────────── */export type JsonKind = "object" | "array" | "string" | "number" | "boolean" | "null";export type JsonChange = "same" | "added" | "removed" | "changed";export type JsonKey = string | number;export interface JsonNode {  /** The path as a string, stable across renders. */  id: string;  key: JsonKey | null;  path: JsonKey[];  depth: number;  kind: JsonKind;  /** Primitives only. */  value?: string | number | boolean | null;  children?: JsonNode[];  /** Keys or items in a container. */  size?: number;  /** Diff only. */  change?: JsonChange;  /** The old value of a changed primitive (or a summary of an old container). */  before?: string | number | boolean | null;  beforeKind?: JsonKind;  /** Changes at or below this node. */  counts?: { added: number; removed: number; changed: number };}const kindOf = (v: unknown): JsonKind =>  v === null || v === undefined ? "null" : Array.isArray(v) ? "array" : typeof v === "object" ? "object" : typeof v === "number" || typeof v === "bigint" ? "number" : typeof v === "boolean" ? "boolean" : "string";const prim = (v: unknown): string | number | boolean | null => {  if (v === null || v === undefined) return null;  if (typeof v === "number" || typeof v === "boolean" || typeof v === "string") return v;  if (typeof v === "bigint") return Number(v);  return String(v);};export const idOf = (path: JsonKey[]) => (path.length ? path.map((k) => (typeof k === "number" ? `[${k}]` : `.${k.replace(/[.[\]\\]/g, "\\$&")}`)).join("") : "$");const entries = (v: unknown): [JsonKey, unknown][] => (Array.isArray(v) ? v.map((x, i) => [i, x] as [JsonKey, unknown]) : Object.entries(v as Record<string, unknown>));/** Any value as a tree. Circular references become the string "[circular]". */export function buildTree(value: unknown, path: JsonKey[] = [], seen = new WeakSet<object>()): JsonNode {  const kind = kindOf(value);  const node: JsonNode = { id: idOf(path), key: path.length ? path[path.length - 1] : null, path, depth: path.length, kind };  if (kind === "object" || kind === "array") {    if (seen.has(value as object)) return { ...node, kind: "string", value: "[circular]" };    seen.add(value as object);    node.children = entries(value).map(([k, v]) => buildTree(v, [...path, k], seen));    node.size = node.children.length;    seen.delete(value as object);  } else node.value = prim(value);  return node;}const mark = (node: JsonNode, change: JsonChange): JsonNode => {  const children = node.children?.map((c) => mark(c, change));  const one = { added: change === "added" ? 1 : 0, removed: change === "removed" ? 1 : 0, changed: 0 };  return { ...node, children, change, counts: one };};const same = (a: unknown, b: unknown) => a === b || (Number.isNaN(a) && Number.isNaN(b));/** One tree that shows how `before` became `after`. */export function diffTree(before: unknown, after: unknown, { arrayKey, path = [] }: { arrayKey?: string; path?: JsonKey[] } = {}): JsonNode {  const ka = kindOf(before);  const kb = kindOf(after);  const base: JsonNode = { id: idOf(path), key: path.length ? path[path.length - 1] : null, path, depth: path.length, kind: kb };  const containers = (k: JsonKind) => k === "object" || k === "array";  if (ka !== kb || !containers(kb)) {    if (!containers(ka) && !containers(kb) && same(before, after)) return { ...base, value: prim(after), change: "same", counts: { added: 0, removed: 0, changed: 0 } };    // A changed primitive (or a container that became something else): old value shown before the new.    const fresh = buildTree(after, path);    return {      ...fresh,      change: "changed",      before: containers(ka) ? (ka === "array" ? `[${(before as unknown[]).length} items]` : `{${Object.keys(before as object).length} keys}`) : prim(before),      beforeKind: ka,      counts: { added: 0, removed: 0, changed: 1 },    };  }  const children: JsonNode[] = [];  if (kb === "array" && arrayKey && [...(before as unknown[]), ...(after as unknown[])].every((x) => x && typeof x === "object" && arrayKey in (x as object))) {    // Records matched by their key, so an item inserted at the top doesn't make every row "changed".    const keyOf = (x: unknown) => String((x as Record<string, unknown>)[arrayKey]);    const old = new Map((before as unknown[]).map((x) => [keyOf(x), x]));    const kept = new Set<string>();    (after as unknown[]).forEach((x, i) => {      const k = keyOf(x);      kept.add(k);      children.push(old.has(k) ? diffTree(old.get(k), x, { arrayKey, path: [...path, i] }) : mark(buildTree(x, [...path, i]), "added"));    });    (before as unknown[]).forEach((x, i) => {      if (!kept.has(keyOf(x))) children.push(mark(buildTree(x, [...path, `${i} (removed)`]), "removed"));    });  } else {    const a = new Map(entries(before));    const b = entries(after);    for (const [k, v] of b) children.push(a.has(k) ? diffTree(a.get(k), v, { arrayKey, path: [...path, k] }) : mark(buildTree(v, [...path, k]), "added"));    const inAfter = new Set(b.map(([k]) => k));    for (const [k, v] of a) if (!inAfter.has(k)) children.push(mark(buildTree(v, [...path, k]), "removed"));  }  const counts = { added: 0, removed: 0, changed: 0 };  for (const c of children) {    // A whole added or removed subtree counts once.    if (c.change === "added") counts.added++;    else if (c.change === "removed") counts.removed++;    else if (c.counts) {      counts.added += c.counts.added;      counts.removed += c.counts.removed;      counts.changed += c.counts.changed;    }  }  const changed = counts.added + counts.removed + counts.changed > 0;  return { ...base, children, size: children.length, change: changed ? "changed" : "same", counts };}/** Every node, depth first, with a map from each id to its parent's. */export function flatten(root: JsonNode) {  const all: JsonNode[] = [];  const parent = new Map<string, string>();  const walk = (n: JsonNode) => {    all.push(n);    n.children?.forEach((c) => {      parent.set(c.id, n.id);      walk(c);    });  };  walk(root);  return { all, parent };}const IDENT = /^[A-Za-z_$][\w$]*$/;/** The path to copy: data.items[2].id, or .data.items[2].id for jq. */export function formatPath(path: JsonKey[], style: "js" | "jq" = "js") {  let out = "";  for (const k of path) {    if (typeof k === "number") out += `[${k}]`;    else if (/^\d+ \(removed\)$/.test(k)) out += `[${parseInt(k)}]`;    else if (IDENT.test(k)) out += style === "jq" || out ? `.${k}` : k;    else out += style === "jq" ? `.${JSON.stringify(k)}` : `[${JSON.stringify(k)}]`;  }  return style === "jq" ? out || "." : out || "(root)";}/** Ids of nodes whose key or value contains the query, in document order. */export function searchTree(all: JsonNode[], query: string) {  const q = query.trim().toLowerCase();  if (!q) return [];  return all    .filter((n) => (n.key !== null && typeof n.key === "string" && n.key.toLowerCase().includes(q)) || (n.children === undefined && String(n.value).toLowerCase().includes(q)))    .map((n) => n.id);}/** The offset where text stops being JSON, or -1. Browsers word (and place) their errors differently, so we find it ourselves. */export function locateError(text: string): number {  let i = 0;  const fail = (): never => {    throw i;  };  const ws = () => {    while (i < text.length && (text[i] === " " || text[i] === "\n" || text[i] === "\r" || text[i] === "\t")) i++;  };  const string = () => {    i++;    while (i < text.length) {      const c = text[i];      if (c === '"') return void i++;      if (c === "\\") i += 2;      else if (c < " ") fail();      else i++;    }    fail();  };  const value = (): void => {    ws();    const c = text[i];    if (c === "{") {      i++;      ws();      if (text[i] === "}") return void i++;      for (;;) {        ws();        if (text[i] !== '"') fail();        string();        ws();        if (text[i] !== ":") fail();        i++;        value();        ws();        if (text[i] === ",") i++;        else if (text[i] === "}") return void i++;        else fail();      }    }    if (c === "[") {      i++;      ws();      if (text[i] === "]") return void i++;      for (;;) {        value();        ws();        if (text[i] === ",") i++;        else if (text[i] === "]") return void i++;        else fail();      }    }    if (c === '"') return string();    const num = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y;    num.lastIndex = i;    if (num.test(text)) return void (i = num.lastIndex);    for (const w of ["true", "false", "null"]) if (text.startsWith(w, i)) return void (i += w.length);    fail();  };  try {    value();    ws();    if (i < text.length) fail();    return -1;  } catch (at) {    return typeof at === "number" ? at : -1;  }}/** Why and where JSON.parse gave up, as a 1-based line and column. */export function parseError(text: string, error: unknown): { message: string; line: number; column: number } {  const raw = error instanceof Error ? error.message : String(error);  let pos = locateError(text);  if (pos < 0) {    const lc = raw.match(/line (\d+) column (\d+)/);    if (lc) return { message: tidyMessage(raw), line: Number(lc[1]), column: Number(lc[2]) };    const at = raw.match(/position (\d+)/);    pos = at ? Number(at[1]) : 0;  }  const before = text.slice(0, pos).split("\n");  return { message: tidyMessage(raw), line: before.length, column: before[before.length - 1].length + 1 };}const tidyMessage = (raw: string) =>  raw    .replace(/^JSON\.parse: /, "")    .replace(/^JSON Parse error: /, "")    .replace(/ in JSON at position \d+[\s\S]*$/, "")    .replace(/, "[\s\S]*" is not valid JSON$/, "")    .replace(/ at line \d+ column \d+ of the JSON data$/, "")    .replace(/^SyntaxError: /, "");

Registry item: /r/json-inspector.json