{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "skeleton-schema",
  "title": "Skeleton Schema",
  "description": "Schema-driven skeleton for streaming structured LLM output: every field, key, and array slot renders as a dashed empty mold the instant the shape is known, then sets solid field-by-field as values pour in.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/skeleton-schema/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useMemo, useRef, useState, type ReactNode } from \"react\";\n\n// SlipCast — pour streaming, schema-shaped JSON into a visible mold. The\n// moment the schema is known (before a single token of data has arrived) the\n// full shape of the answer renders as a skeleton of empty slots: every key\n// already typeset in Geist Mono at --ns-muted, sitting in a dashed 1px --border\n// blank pre-sized by its declared type (numbers short, booleans a fixed\n// true/false width, strings full-row so a long value never reflows the row\n// that holds it). That skeleton IS the progress bar — there is no separate\n// spinner or bar element anywhere in this component.\n//\n// As each value streams in, its slot — and only its slot — flips from\n// pending to set: the dashed border crossfades to solid over 150ms\n// ease-out-expo (border-style itself can't be animated, so this is two\n// stacked frames whose opacity crosses over, not a literal style\n// interpolation), the value drops in with a small spring overshoot\n// (translateY 3px -> 0), and a 4px corner tick fades --ns-muted -> --foreground.\n// Nested objects get the same dashed->solid treatment on their own\n// container, independent of whether their children have filled yet — a\n// nested object \"sets\" the instant its key exists in the stream, even with\n// every field inside it still dashed. Arrays keep two ghost rows appended\n// past the last arrived item at all times, so the row a value is about to\n// land in was already on screen, dashed, before it filled — nothing new\n// reflows into existence under the incoming value itself, only the trailing\n// ghost buffer grows. Nesting indents in fixed 16px steps.\n//\n// Content that mounts already-complete (a value with every field present on\n// first render) skips the fill animation entirely — the \"live\" transitions\n// only arm after mount, so a component that never streamed doesn't play a\n// pointless cascade.\n//\n// Unlike a generic content-shaped loading skeleton (bars standing in for\n// unknown prose), this is schema-driven and field-granular: real keys are\n// typeset before any value exists, every slot tracks its own pending/set\n// state independently, and a slot still dashed when streaming ends is left\n// exactly as dashed — an honest \"this field never arrived\" rather than a\n// skeleton dissolving into empty space. It renders the *shape* of a\n// streaming payload; it does not own the character-level caret inside a\n// value that is itself still arriving — that's streaming-markdown-caret's job, and the two\n// compose (a string slot's value can itself be a streaming-markdown-caret span).\n//\n// Pure DOM + CSS: real dt/dd pairs inside real dl elements (nested objects\n// get a nested dl, array items a dl per row), role=region with aria-busy\n// while streaming, a visually-hidden aria-live=polite region that announces\n// batched \"N of M fields complete\" milestones (debounced, never per-token),\n// and exactly one focusable control per top-level object: a Copy JSON\n// button. Colors are tokens only (--background --foreground --ns-muted\n// --border --ns-accent); --ns-accent appears only on the copy button's focus\n// ring. prefers-reduced-motion drops every transition — slots simply appear\n// filled, nothing eases.\n\nexport type SlotType = \"string\" | \"number\" | \"boolean\" | \"null\";\nexport type SlotKind = SlotType | \"object\" | \"array\";\n\nexport interface SlotSchema {\n  /** the real field name, typeset verbatim as the dt */\n  key: string;\n  kind: SlotKind;\n  /** children, for kind: \"object\" */\n  fields?: SlotSchema[];\n  /** shape of one array item, for kind: \"array\" — an object shape, or a scalar kind for a flat array */\n  item?: SlotSchema[] | SlotType;\n}\n\nexport type JsonValue =\n  | string\n  | number\n  | boolean\n  | null\n  | JsonValue[]\n  | { [key: string]: JsonValue }\n  | undefined;\n\nexport type JsonRecord = Record<string, JsonValue>;\n\nexport interface SlipCastProps {\n  /** the known shape of the answer, available before any value has arrived */\n  schema: SlotSchema[];\n  /** the accumulated value so far — a deep-partial object; absent keys read as pending */\n  value: JsonRecord | undefined;\n  /** true while more values are still expected; drives aria-busy and the milestone copy */\n  streaming?: boolean;\n  /** accessible name for the region, and the noun the copy button acts on */\n  label?: string;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nconst GHOST_ROWS_AHEAD = 2;\nconst ANNOUNCE_DEBOUNCE_MS = 350;\n\nfunction hasOwn(obj: unknown, key: string): obj is Record<string, JsonValue> {\n  return typeof obj === \"object\" && obj !== null && Object.prototype.hasOwnProperty.call(obj, key);\n}\n\nfunction getAtPath(root: JsonValue, path: string): { present: boolean; value: JsonValue } {\n  let cur: JsonValue = root;\n  for (const part of path.split(\".\")) {\n    if (!hasOwn(cur, part)) return { present: false, value: undefined };\n    cur = (cur as JsonRecord)[part];\n    if (cur === undefined) return { present: false, value: undefined };\n  }\n  return { present: true, value: cur };\n}\n\n/** Flattens the schema into the fixed set of slots the milestone count is\n * measured against. Arrays count as exactly one slot — their item rows are\n * unbounded and streaming, so they're excluded from the denominator or \"4 of\n * 9\" would keep retargeting mid-stream. */\nfunction flattenSchema(fields: SlotSchema[], base = \"\"): string[] {\n  const out: string[] = [];\n  for (const f of fields) {\n    const path = base ? `${base}.${f.key}` : f.key;\n    out.push(path);\n    if (f.kind === \"object\" && f.fields) out.push(...flattenSchema(f.fields, path));\n  }\n  return out;\n}\n\nfunction formatScalar(kind: SlotType, value: JsonValue): ReactNode {\n  if (kind === \"null\") return <span className=\"italic\">null</span>;\n  if (kind === \"boolean\") return value ? \"true\" : \"false\";\n  if (kind === \"number\") return typeof value === \"number\" ? value.toLocaleString() : String(value ?? \"\");\n  return typeof value === \"string\" ? value : String(value ?? \"\");\n}\n\nfunction ValueSlot({ kind, present, value }: { kind: SlotType; present: boolean; value: JsonValue }) {\n  return (\n    <span className={`ns-slip-value-wrap ns-slip-${kind}`} data-state={present ? \"set\" : \"pending\"}>\n      <span className=\"ns-slip-edge ns-slip-edge-dashed\" aria-hidden=\"true\" />\n      <span className=\"ns-slip-edge ns-slip-edge-solid\" aria-hidden=\"true\" />\n      <span className=\"ns-slip-tick\" aria-hidden=\"true\" />\n      <span className=\"ns-slip-value\">{present ? formatScalar(kind, value) : \"\"}</span>\n    </span>\n  );\n}\n\nfunction ObjectSlot({ present, children }: { present: boolean; children: ReactNode }) {\n  // a div, not a span: a <dl> is flow content and cannot legally nest inside\n  // phrasing content — a span wrapper here would get silently reparented by\n  // the HTML parser, taking the edge-overlay positioning context with it\n  return (\n    <div className=\"ns-slip-container\" data-state={present ? \"set\" : \"pending\"}>\n      <span className=\"ns-slip-edge ns-slip-edge-dashed\" aria-hidden=\"true\" />\n      <span className=\"ns-slip-edge ns-slip-edge-solid\" aria-hidden=\"true\" />\n      <span className=\"ns-slip-tick\" aria-hidden=\"true\" />\n      <dl className=\"ns-slip-dl\">{children}</dl>\n    </div>\n  );\n}\n\nfunction renderField(field: SlotSchema, parent: JsonValue, depth: number, keyPrefix: string): ReactNode {\n  const present = hasOwn(parent, field.key);\n  const raw = present ? (parent as JsonRecord)[field.key] : undefined;\n  const rowKey = `${keyPrefix}.${field.key}`;\n  const indent = { paddingLeft: depth * 16 };\n\n  if (field.kind === \"object\") {\n    const nested = field.fields ?? [];\n    return (\n      <div className=\"ns-slip-row\" style={indent} key={rowKey}>\n        <dt className=\"ns-slip-key\">{field.key}</dt>\n        <dd>\n          <ObjectSlot present={present}>\n            {nested.map((child) => renderField(child, raw, depth + 1, rowKey))}\n          </ObjectSlot>\n        </dd>\n      </div>\n    );\n  }\n\n  if (field.kind === \"array\") {\n    const arr = present && Array.isArray(raw) ? raw : [];\n    const rowCount = arr.length + GHOST_ROWS_AHEAD;\n    const itemFields = Array.isArray(field.item) ? field.item : undefined;\n    const scalarKind: SlotType = typeof field.item === \"string\" ? field.item : \"string\";\n    return (\n      <div className=\"ns-slip-row\" style={indent} key={rowKey}>\n        <dt className=\"ns-slip-key\">{field.key}</dt>\n        <dd>\n          <ObjectSlot present={present}>\n            {Array.from({ length: rowCount }, (_, i) => {\n              const itemPresent = i < arr.length;\n              const itemValue = itemPresent ? arr[i] : undefined;\n              const itemRowKey = `${rowKey}.${i}`;\n              if (itemFields) {\n                return (\n                  <div className=\"ns-slip-row\" style={{ paddingLeft: (depth + 1) * 16 }} key={itemRowKey}>\n                    <dt className=\"ns-slip-index\">[{i}]</dt>\n                    <dd>\n                      <ObjectSlot present={itemPresent}>\n                        {itemFields.map((child) => renderField(child, itemValue, depth + 2, itemRowKey))}\n                      </ObjectSlot>\n                    </dd>\n                  </div>\n                );\n              }\n              return (\n                <div className=\"ns-slip-row\" style={{ paddingLeft: (depth + 1) * 16 }} key={itemRowKey}>\n                  <dt className=\"ns-slip-index\">[{i}]</dt>\n                  <dd>\n                    <ValueSlot kind={scalarKind} present={itemPresent} value={itemValue} />\n                  </dd>\n                </div>\n              );\n            })}\n          </ObjectSlot>\n        </dd>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"ns-slip-row\" style={indent} key={rowKey}>\n      <dt className=\"ns-slip-key\">{field.key}</dt>\n      <dd>\n        <ValueSlot kind={field.kind} present={present} value={raw} />\n      </dd>\n    </div>\n  );\n}\n\nasync function copyToClipboard(text: string): Promise<boolean> {\n  try {\n    if (navigator.clipboard?.writeText) {\n      await navigator.clipboard.writeText(text);\n      return true;\n    }\n  } catch {\n    // fall through to the legacy path below\n  }\n  try {\n    const ta = document.createElement(\"textarea\");\n    ta.value = text;\n    ta.style.position = \"fixed\";\n    ta.style.opacity = \"0\";\n    document.body.appendChild(ta);\n    ta.focus();\n    ta.select();\n    const ok = document.execCommand(\"copy\");\n    document.body.removeChild(ta);\n    return ok;\n  } catch {\n    return false;\n  }\n}\n\nconst CSS = `\n.ns-skeleton-schema dt,.ns-skeleton-schema dd,.ns-skeleton-schema dl{margin:0;padding:0}\n.ns-skeleton-schema .ns-slip-dl{display:flex;flex-direction:column;gap:8px}\n.ns-skeleton-schema .ns-slip-row{display:flex;align-items:flex-start;gap:12px}\n.ns-skeleton-schema .ns-slip-row > dd{flex:1;min-width:0;display:flex}\n.ns-skeleton-schema .ns-slip-key,.ns-skeleton-schema .ns-slip-index{\n  flex:0 0 auto;min-width:64px;padding-top:3px;\n  font-family:var(--font-mono);font-size:11.5px;line-height:18px;color:var(--ns-muted);white-space:nowrap;\n}\n.ns-skeleton-schema .ns-slip-index{opacity:.75;min-width:32px}\n\n.ns-skeleton-schema .ns-slip-container{position:relative;display:block;flex:1;padding:8px;border-radius:12px}\n.ns-skeleton-schema .ns-slip-container > .ns-slip-dl{position:relative}\n\n.ns-skeleton-schema .ns-slip-value-wrap{\n  position:relative;display:inline-flex;align-items:center;box-sizing:border-box;\n  min-height:22px;padding:2px 7px;border-radius:6px;\n}\n.ns-skeleton-schema .ns-slip-string{display:flex;width:100%}\n.ns-skeleton-schema .ns-slip-number{min-width:3.5ch;justify-content:flex-end;font-family:var(--font-mono);font-variant-numeric:tabular-nums}\n.ns-skeleton-schema .ns-slip-boolean{width:5ch;font-family:var(--font-mono)}\n.ns-skeleton-schema .ns-slip-null{width:4ch;font-family:var(--font-mono)}\n\n.ns-skeleton-schema .ns-slip-edge{position:absolute;inset:0;border-radius:inherit;pointer-events:none}\n.ns-skeleton-schema .ns-slip-edge-dashed{border:1px dashed var(--border);opacity:1}\n.ns-skeleton-schema .ns-slip-edge-solid{border:1px solid var(--border);opacity:0}\n.ns-skeleton-schema [data-state=\"set\"] > .ns-slip-edge-dashed{opacity:0}\n.ns-skeleton-schema [data-state=\"set\"] > .ns-slip-edge-solid{opacity:1}\n.ns-skeleton-schema [data-state=\"set\"] > .ns-slip-tick{background:var(--foreground)}\n.ns-skeleton-schema [data-state=\"set\"] > .ns-slip-value{opacity:1;transform:translateY(0)}\n\n.ns-skeleton-schema .ns-slip-tick{\n  position:absolute;top:-1px;left:-1px;width:4px;height:4px;background:var(--ns-muted);\n}\n.ns-skeleton-schema .ns-slip-value{position:relative;font-size:12.5px;color:var(--foreground);opacity:0;transform:translateY(3px)}\n\n/* the fill transitions only arm once mounted-live, so pre-filled content\n   (a value that arrived complete on first render) never plays a cascade it\n   didn't earn */\n.ns-skeleton-schema.ns-slip-live .ns-slip-edge{transition:opacity 150ms cubic-bezier(.16,1,.3,1)}\n.ns-skeleton-schema.ns-slip-live .ns-slip-tick{transition:background-color 200ms ease-out}\n.ns-skeleton-schema.ns-slip-live .ns-slip-value{transition:transform 320ms cubic-bezier(.34,1.56,.64,1),opacity 200ms ease-out}\n\n.ns-skeleton-schema .ns-slip-copy{outline:none}\n.ns-skeleton-schema .ns-slip-copy:focus-visible{outline:2px solid var(--ns-accent);outline-offset:2px}\n\n@media (prefers-reduced-motion: reduce){\n  .ns-skeleton-schema .ns-slip-edge,.ns-skeleton-schema .ns-slip-tick,.ns-skeleton-schema .ns-slip-value{transition:none !important}\n}\n`;\n\nexport function SlipCast({ schema, value, streaming = false, label = \"Structured output\", className = \"\" }: SlipCastProps) {\n  const [live, setLive] = useState(false);\n  const [copied, setCopied] = useState(false);\n  const [announced, setAnnounced] = useState(\"\");\n  const copyTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n  const announceTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n\n  useEffect(() => {\n    // arms the fill transitions one frame after mount so a value that is\n    // already complete on first paint renders filled instantly, not animated\n    const id = requestAnimationFrame(() => setLive(true));\n    return () => cancelAnimationFrame(id);\n  }, []);\n\n  const flat = useMemo(() => flattenSchema(schema), [schema]);\n  const filled = useMemo(() => flat.filter((path) => getAtPath(value, path).present).length, [flat, value]);\n  const total = flat.length;\n\n  useEffect(() => {\n    if (announceTimer.current) clearTimeout(announceTimer.current);\n    announceTimer.current = setTimeout(() => {\n      setAnnounced(`${filled} of ${total} field${total === 1 ? \"\" : \"s\"} complete`);\n    }, ANNOUNCE_DEBOUNCE_MS);\n    return () => {\n      if (announceTimer.current) clearTimeout(announceTimer.current);\n    };\n  }, [filled, total]);\n\n  useEffect(\n    () => () => {\n      if (copyTimer.current) clearTimeout(copyTimer.current);\n    },\n    [],\n  );\n\n  const handleCopy = async () => {\n    const ok = await copyToClipboard(JSON.stringify(value ?? {}, null, 2));\n    if (!ok) return;\n    setCopied(true);\n    if (copyTimer.current) clearTimeout(copyTimer.current);\n    copyTimer.current = setTimeout(() => setCopied(false), 1500);\n  };\n\n  return (\n    <div\n      role=\"region\"\n      aria-busy={streaming}\n      aria-label={label}\n      className={`ns-skeleton-schema ${live ? \"ns-slip-live\" : \"\"} ${className}`}\n    >\n      <style>{CSS}</style>\n\n      <div className=\"mb-3 flex items-center justify-between gap-3\">\n        <span className=\"font-mono text-[11px] uppercase tracking-[0.14em] text-ns-muted\">{label}</span>\n        <button\n          type=\"button\"\n          className=\"ns-slip-copy rounded-sm border border-border px-2 py-1 font-mono text-[11px] text-ns-muted transition-colors duration-150 hover:text-foreground\"\n          onClick={handleCopy}\n          aria-label={copied ? `${label} copied to clipboard` : `Copy ${label} as JSON`}\n        >\n          {copied ? \"Copied\" : \"Copy JSON\"}\n        </button>\n      </div>\n\n      <dl className=\"ns-slip-dl\">{schema.map((field) => renderField(field, value, 0, \"root\"))}</dl>\n\n      <p aria-live=\"polite\" className=\"sr-only\">\n        {announced}\n      </p>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/skeleton-schema.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "skeleton",
      "streaming",
      "json",
      "schema",
      "llm",
      "tool-calls",
      "structured-output",
      "definition-list",
      "developer-tools"
    ],
    "instruction": "Renders the full shape of a structured LLM answer — a tool call's arguments, a JSON-mode response — as a skeleton the instant its schema resolves, before a single value has arrived: `<SlipCast schema={schema} value={value} streaming={isStreaming} label=\"search_flights arguments\" />`. `schema` is an array of `SlotSchema` (`{ key, kind: \"string\"|\"number\"|\"boolean\"|\"null\"|\"object\"|\"array\", fields?, item? }`) describing every field up front; `value` is the accumulated deep-partial object so far, passed fresh on every render exactly like a streaming buffer, and re-derived (never mutated) each time a new key lands. A field absent from `value` (or explicitly `undefined`) reads as pending; any other value, including an explicit `null` on a `kind:\"null\"` field, reads as set — so a real streamed `null` is honestly rendered as arrived, not confused with 'hasn't shown up yet'. LAYOUT: real `dt`/`dd` pairs inside a real `dl` (nested objects get their own nested `dl`, array items a `dl` per row), keys typeset in Geist Mono at `--ns-muted` and never localized or reformatted, value blanks pre-sized by type — numbers a narrow tabular-nums box, booleans a fixed five-character box (fits both 'true' and 'false' without resizing on flip), null a fixed four-character box, strings full row width so an arbitrarily long value never reflows the row around it. Nesting indents in fixed 16px steps. THE FILL: a pending slot is a 1px dashed `--border` box; the moment its value lands the box crossfades to a 1px solid `--border` box over 150ms ease-out-expo (border-style itself isn't an animatable CSS property, so this is two stacked frames trading opacity, not a literal style tween — visually indistinguishable from one), the value drops in with a 320ms spring overshoot (opacity + translateY 3px -> 0), and a 4px corner tick fades `--ns-muted` -> `--foreground`. A nested object's own container sets the instant its key exists in `value`, independent of how many of its children have filled — a field-granular pending/set state per slot, not one loading flag for the whole payload. ARRAYS: at every moment the array renders `arrived.length + 2` rows — the two ghost rows past the stream head are already on screen, dashed, before their values exist, so the row a value is about to land in never appears out of nowhere; only the trailing ghost buffer grows as items keep arriving. A slot still dashed when `streaming` goes false stays dashed — an honest 'this field never arrived' failure state, not swept away. Content that mounts already-complete (a value with every field present on first paint) skips the fill animation entirely; transitions only arm one frame after mount, via a `live` class, so nothing plays a cascade it didn't earn. DISTINCT FROM skeleton-develop: that component is a generic content-shaped loading placeholder (bars standing in for unknown prose, one loading flag, blocks that develop into unmeasured children on a single transition) — this one is schema-driven and field-granular: the real keys are typeset before any data exists, every individual slot tracks its own pending/set state, and it is built for the exact shape of streaming JSON/tool-call payloads, not prose. It also sits one layer above streaming-markdown-caret: streaming-markdown-caret owns the character-level insertion point inside a value that is itself still arriving token-by-token (a string slot's value can be a streaming-markdown-caret span); this component owns the contract-level shape around it and has no opinion about mid-token rendering. ACCESSIBILITY: the container is `role=region` with `aria-busy` while `streaming`; a visually-hidden `aria-live=polite` node announces batched milestones ('4 of 9 fields complete'), debounced 350ms so a fast burst of arrivals collapses into one announcement rather than one per key; the only focusable control is a Copy JSON button per top-level object, which serializes the current `value` with `JSON.stringify(value, null, 2)` and confirms with a transient label swap rather than a second element. prefers-reduced-motion drops every transition — slots that set simply appear filled, solid, and ticked with no easing. Pure DOM + CSS, zero dependencies, no canvas; every color is a token (--background --foreground --ns-muted --border --ns-accent), with --ns-accent appearing only on the copy button's focus ring."
  },
  "type": "registry:ui"
}