{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "inspector",
  "type": "registry:ui",
  "title": "Inspector",
  "description": "A floating glass panel for tuning one thing live, built from litt's own controls: groups that fold, versions you save and go back to, a check against your rules with a Fits or Change row each, and Copy tokens.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/button.json",
    "https://littmd.vercel.app/r/icons.json",
    "https://littmd.vercel.app/r/motion.json",
    "https://littmd.vercel.app/r/select.json",
    "https://littmd.vercel.app/r/tag.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/inspector.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/inspector 2026.10.27. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Inspector: a floating panel for tuning one thing live, built from litt's own controls. Groups that fold, versions\n * you save and go back to, a check against your rules, and the result copied as tokens.\n *\n * - Surface: floating glass (R3, litt-glass) at --litt-radius-surface, 320px wide and never wider than its container. The\n *   caller places it: beside the thing it tunes, or fixed in a corner. If the caller caps its height, the body\n *   scrolls inside it and the header stays.\n * - Header: a 44px row with the sliders icon, the title at the small size and --litt-weight-title, Reset (a ghost sm\n *   button, only while the value differs from defaultValue) and an icon-only ghost button that folds the panel to its\n *   header. Its chevron turns half a turn on the responsive tier; the body snaps, never animating its height, and\n *   fades in on the responsive tier.\n * - Versions: a Select (sm) of the presets and the versions you saved, with Save beside it (a secondary sm button).\n *   Save keeps the current value as \"Version 1\", \"Version 2\" and so on; picking one puts its value back. While the\n *   value matches none of them, the Select says \"Unsaved changes\". A saved version that is picked can be deleted with\n *   the trash button. With storageKey, saved versions live in localStorage under it; up to twelve, the oldest goes\n *   first. A polite status says \"Saved as Version 3\".\n * - Groups: InspectorGroup is one fold: a full-width row (32px, 44px on a coarse pointer) with the title at the small\n *   size and --litt-weight-title, an optional summary in --litt-fg-subtle and a chevron, over its controls. Groups are split by\n *   --litt-line hairlines, as the Accordion's rows are. Put any litt control inside: Slider, Switch, Segmented, Select,\n *   Pad, Spring editor, Curve editor.\n * - Rules: pass checks and a Rules group lists one row per rule, each with a Fits or Change tag (success and warning\n *   words, never fills), as the Playground's rows do. Its summary says \"All fit\" or \"2 to change\".\n * - Tokens: pass tokens and the footer has Copy tokens (a secondary sm button that says Copied for two seconds), with\n *   a Tokens group that shows them in mono, since they are code. Where the clipboard is refused, the text is selected\n *   instead, so a copy shortcut finishes the job.\n * - No accent fill (R6): the inspector is chrome around the page's one main action, never the action itself.\n * - A Select inside it opens solid (--litt-surface-2), never glass on glass.\n */\nimport { useEffect, useId, useRef, useState, type ReactNode } from \"react\";\nimport { motion } from \"motion/react\";\nimport { useTransition } from \"../../lib/litt/motion\";\nimport { Button } from \"./button\";\nimport { CheckIcon, ChevronDownIcon, CopyIcon, SlidersIcon, TrashIcon } from \"./icons\";\nimport { Select } from \"./select\";\nimport { Tag } from \"./tag\";\n\n/** One saved value with its name, such as a preset or a version you kept. */\nexport type InspectorVersion<T> = {\n  /** What the version list shows, such as \"Calm\" or \"Version 2\". */\n  label: string;\n  /** The value it puts back. */\n  value: T;\n};\n\n/** One rule the value is checked against. */\nexport type InspectorCheck = {\n  /** True when the value fits the rule. */\n  ok: boolean;\n  /** The rule and the value in words, such as \"Bounce at most 0.12: 0.2\". */\n  text: string;\n};\n\nexport type InspectorProps<T> = {\n  /** What is being tuned, such as \"Toast\". It names the panel for screen readers. */\n  title: ReactNode;\n  /** Every value the panel tunes, as one object. Versions save it and put it back. */\n  value: T;\n  /** Called with a whole value when a version, a preset or Reset puts one back. The controls inside call their own handlers. */\n  onValueChange: (value: T) => void;\n  /** The value Reset goes back to. Without it there is no Reset. */\n  defaultValue?: T;\n  /** Named starting points, listed before the saved versions. */\n  presets?: InspectorVersion<T>[];\n  /** Keeps saved versions in localStorage under this key. Use one key per shape of value, and change it when the shape changes. Without it, versions last as long as the page. */\n  storageKey?: string;\n  /** Checks the value against your rules. Each row says Fits or Change, in a Rules group. */\n  checks?: (value: T) => InspectorCheck[];\n  /** Writes the value as tokens to paste, such as CSS custom properties. Adds Copy tokens and a Tokens group. */\n  tokens?: (value: T) => string;\n  /** Whether the panel is open, when you control it. Pair it with onOpenChange. */\n  open?: boolean;\n  /** Whether it starts open when you do not control it. Open by default. */\n  defaultOpen?: boolean;\n  /** Called with the new state when the panel folds or opens. */\n  onOpenChange?: (open: boolean) => void;\n  /** The groups, each an InspectorGroup. */\n  children: ReactNode;\n  /** Extra classes for the panel, such as where it sits or a max height. */\n  className?: string;\n};\n\nexport type InspectorGroupProps = {\n  /** The group's name, such as \"Shape\" or \"Motion\". */\n  title: ReactNode;\n  /** A few words at the end of the group's row, such as the value it holds. They stay visible while it is folded. */\n  summary?: ReactNode;\n  /** Whether it starts open. Open by default. */\n  defaultOpen?: boolean;\n  /** The controls. */\n  children: ReactNode;\n};\n\nconst MAX_SAVED = 12;\nconst same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);\n\nfunction readSaved<T>(key?: string): InspectorVersion<T>[] {\n  if (!key || typeof localStorage === \"undefined\") return [];\n  try {\n    const raw = JSON.parse(localStorage.getItem(key) || \"[]\");\n    return Array.isArray(raw) ? raw.filter((v) => v && typeof v.label === \"string\" && \"value\" in v) : [];\n  } catch {\n    return [];\n  }\n}\n\n/** A chevron that turns half a turn when open, on the responsive tier. */\nfunction Chevron({ open }: { open: boolean }) {\n  const turn = useTransition(\"responsive\");\n  return (\n    <motion.span aria-hidden=\"true\" className=\"inline-flex text-litt-fg-subtle\" initial={false} animate={{ rotate: open ? 180 : 0 }} transition={turn}>\n      <ChevronDownIcon />\n    </motion.span>\n  );\n}\n\n/** The body of a fold: it snaps in, then fades on the responsive tier. Its height never animates. */\nfunction Fold({ id, labelledBy, className, children }: { id: string; labelledBy: string; className: string; children: ReactNode }) {\n  const enter = useTransition(\"responsive\");\n  return (\n    <motion.div id={id} role=\"group\" aria-labelledby={labelledBy} className={className} initial={{ opacity: 0 }} animate={{ opacity: 1, transition: enter }}>\n      {children}\n    </motion.div>\n  );\n}\n\nconst row =\n  \"flex w-full cursor-pointer items-center gap-2 px-3 text-left text-litt-small font-litt-title text-litt-fg h-litt-control-sm pointer-coarse:h-litt-control-md \" +\n  \"transition-colors duration-(--litt-dur-instant) ease-litt hover:bg-[color-mix(in_oklab,var(--litt-fg)_4%,transparent)] \" +\n  \"pointer-coarse:active:bg-[color-mix(in_oklab,var(--litt-fg)_6%,transparent)] \" +\n  \"focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-litt-focus\";\n\n/** One fold of the inspector: a row that opens and closes, over the controls it holds. */\nexport function InspectorGroup({ title, summary, defaultOpen = true, children }: InspectorGroupProps) {\n  const id = useId().replace(/[^\\w-]/g, \"\");\n  const [open, setOpen] = useState(defaultOpen);\n  return (\n    <div className=\"border-t border-litt-line\">\n      <button type=\"button\" id={`${id}-row`} aria-expanded={open} aria-controls={`${id}-body`} onClick={() => setOpen((o) => !o)} className={row}>\n        <span className=\"min-w-0 flex-1 truncate\">{title}</span>\n        {summary !== undefined && <span className=\"shrink-0 text-litt-caption font-normal tabular-nums text-litt-fg-subtle\">{summary}</span>}\n        <Chevron open={open} />\n      </button>\n      {open && (\n        <Fold id={`${id}-body`} labelledBy={`${id}-row`} className=\"grid gap-5 px-3 pt-2 pb-4\">\n          {children}\n        </Fold>\n      )}\n    </div>\n  );\n}\n\n/** A floating panel of litt controls for tuning one thing live: folding groups, saved versions, a rules check and Copy tokens. */\nexport function Inspector<T>({\n  title,\n  value,\n  onValueChange,\n  defaultValue,\n  presets = [],\n  storageKey,\n  checks,\n  tokens,\n  open: openProp,\n  defaultOpen = true,\n  onOpenChange,\n  children,\n  className = \"\",\n}: InspectorProps<T>) {\n  const id = useId().replace(/[^\\w-]/g, \"\");\n  const titleId = `${id}-title`;\n  const bodyId = `${id}-body`;\n  const tokensId = `${id}-tokens`;\n\n  const [openInner, setOpenInner] = useState(defaultOpen);\n  const open = openProp ?? openInner;\n  const setOpen = (next: boolean) => {\n    if (openProp === undefined) setOpenInner(next);\n    onOpenChange?.(next);\n  };\n\n  /* Saved versions are read after mount, so the server and the first paint agree. */\n  const [saved, setSaved] = useState<InspectorVersion<T>[]>([]);\n  const loaded = useRef(false);\n  useEffect(() => {\n    setSaved(readSaved<T>(storageKey));\n    loaded.current = true;\n  }, [storageKey]);\n  useEffect(() => {\n    if (!loaded.current || !storageKey) return;\n    try {\n      if (saved.length) localStorage.setItem(storageKey, JSON.stringify(saved));\n      else localStorage.removeItem(storageKey);\n    } catch {\n      /* storage can be full or refused; the versions still last the visit */\n    }\n  }, [saved, storageKey]);\n\n  const [status, setStatus] = useState(\"\");\n  const [copied, setCopied] = useState(false);\n  const copiedTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n  useEffect(() => () => clearTimeout(copiedTimer.current), []);\n\n  const options = [\n    ...presets.map((p, i) => ({ value: `preset-${i}`, label: p.label, item: p })),\n    ...saved.map((s, i) => ({ value: `saved-${i}`, label: s.label, item: s })),\n  ];\n  const current = options.find((o) => same(o.item.value, value));\n  const pickedSaved = current?.value.startsWith(\"saved-\") ? Number(current.value.slice(6)) : -1;\n  const differs = defaultValue !== undefined && !same(value, defaultValue);\n\n  const save = () => {\n    const taken = new Set(saved.map((s) => s.label));\n    let n = saved.length + 1;\n    while (taken.has(`Version ${n}`)) n++;\n    const label = `Version ${n}`;\n    setSaved((list) => [...list, { label, value: structuredClone(value) }].slice(-MAX_SAVED));\n    setStatus(`Saved as ${label}.`);\n  };\n  const remove = (i: number) => {\n    const label = saved[i]?.label;\n    setSaved((list) => list.filter((_, j) => j !== i));\n    if (label) setStatus(`Deleted ${label}.`);\n  };\n\n  const rows = checks ? checks(value) : [];\n  const toChange = rows.filter((r) => !r.ok).length;\n  const text = tokens ? tokens(value) : \"\";\n\n  const copy = async () => {\n    try {\n      await navigator.clipboard.writeText(text);\n      setCopied(true);\n      setStatus(\"Copied the tokens.\");\n      clearTimeout(copiedTimer.current);\n      copiedTimer.current = setTimeout(() => setCopied(false), 2000);\n    } catch {\n      const pre = document.getElementById(tokensId);\n      if (pre) {\n        const range = document.createRange();\n        range.selectNodeContents(pre);\n        const sel = getSelection();\n        sel?.removeAllRanges();\n        sel?.addRange(range);\n        setStatus(\"The clipboard is not available here, so the tokens are selected. Copy them with your shortcut.\");\n      } else {\n        setStatus(\"The clipboard is not available here. Open Tokens to copy them by hand.\");\n      }\n    }\n  };\n\n  return (\n    <section aria-labelledby={titleId} className={\"litt-glass flex w-full max-w-80 min-h-0 flex-col overflow-hidden rounded-litt-surface \" + className}>\n      <div className=\"flex h-litt-control-md shrink-0 items-center gap-2 pr-1 pl-3\">\n        <SlidersIcon aria-hidden=\"true\" className=\"shrink-0 text-litt-fg-subtle\" />\n        <span id={titleId} className=\"min-w-0 flex-1 truncate text-litt-small font-litt-title text-litt-fg\">\n          {title}\n        </span>\n        {differs && open && (\n          <Button size=\"sm\" variant=\"ghost\" onClick={() => onValueChange(structuredClone(defaultValue as T))}>\n            Reset\n          </Button>\n        )}\n        <Button\n          size=\"sm\"\n          variant=\"ghost\"\n          iconOnly\n          aria-label={open ? `Fold ${typeof title === \"string\" ? title : \"the panel\"}` : `Open ${typeof title === \"string\" ? title : \"the panel\"}`}\n          aria-expanded={open}\n          aria-controls={bodyId}\n          icon={<Chevron open={open} />}\n          onClick={() => setOpen(!open)}\n        />\n      </div>\n\n      {open && (\n        <Fold id={bodyId} labelledBy={titleId} className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain\">\n          <div className=\"flex items-center gap-2 border-t border-litt-line px-3 py-3\">\n            <div className=\"min-w-0 flex-1\">\n              <Select\n                size=\"sm\"\n                aria-label=\"Version\"\n                options={options.map(({ value: v, label }) => ({ value: v, label }))}\n                value={current?.value ?? \"\"}\n                placeholder={options.length ? \"Unsaved changes\" : \"No versions yet\"}\n                onValueChange={(v) => {\n                  const hit = options.find((o) => o.value === v);\n                  if (hit) onValueChange(structuredClone(hit.item.value));\n                }}\n                disabled={options.length === 0}\n              />\n            </div>\n            {pickedSaved >= 0 && (\n              <Button size=\"sm\" variant=\"ghost\" iconOnly aria-label={`Delete ${saved[pickedSaved]?.label}`} icon={<TrashIcon />} onClick={() => remove(pickedSaved)} />\n            )}\n            <Button size=\"sm\" onClick={save} disabled={current !== undefined}>\n              Save\n            </Button>\n          </div>\n\n          {children}\n\n          {rows.length > 0 && (\n            <InspectorGroup title=\"Rules\" summary={toChange ? `${toChange} to change` : \"All fit\"}>\n              <ul className=\"grid gap-2\">\n                {rows.map((r) => (\n                  <li key={r.text} className=\"flex items-start gap-2 text-litt-small\">\n                    <Tag status={r.ok ? \"success\" : \"warning\"}>{r.ok ? \"Fits\" : \"Change\"}</Tag>\n                    <span className={\"pt-0.5 \" + (r.ok ? \"text-litt-fg-subtle\" : \"text-litt-fg-muted\")}>{r.text}</span>\n                  </li>\n                ))}\n              </ul>\n            </InspectorGroup>\n          )}\n\n          {tokens && (\n            <InspectorGroup title=\"Tokens\" defaultOpen={false}>\n              <pre\n                id={tokensId}\n                tabIndex={0}\n                aria-label=\"Tokens to paste\"\n                className=\"max-h-64 overflow-auto rounded-litt-control bg-litt-surface-2 p-3 font-litt-mono text-litt-caption font-normal whitespace-pre text-litt-fg-muted shadow-[inset_0_0_0_1px_var(--litt-line)]\"\n              >\n                {text}\n              </pre>\n            </InspectorGroup>\n          )}\n\n          {tokens && (\n            <div className=\"border-t border-litt-line p-3\">\n              <Button size=\"sm\" block icon={copied ? <CheckIcon /> : <CopyIcon />} onClick={copy}>\n                {copied ? \"Copied\" : \"Copy tokens\"}\n              </Button>\n            </div>\n          )}\n        </Fold>\n      )}\n      <p role=\"status\" className=\"sr-only\">\n        {status}\n      </p>\n    </section>\n  );\n}\n",
      "target": "components/litt/inspector.tsx"
    }
  ],
  "docs": "Import Inspector and InspectorGroup from components/litt/inspector. Hold every value in one object and pass it as value with onValueChange, then put a Slider, a Switch, a Spring editor or a Curve editor in each InspectorGroup. Add defaultValue for Reset, presets and storageKey for versions, checks for the rules and tokens for Copy tokens.",
  "meta": {
    "version": "2026.10.27",
    "released": "2026-10-27",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
