{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "inspector",
  "type": "registry:ui",
  "title": "Inspector",
  "description": "Compact, sectioned property panel for editing the current selection with controlled dot-path values and number, text, switch, and select controls.",
  "dependencies": [],
  "devDependencies": [],
  "registryDependencies": [
    "https://components.dethink.co.uk/r/dethink-base.json",
    "https://components.dethink.co.uk/r/empty-state.json",
    "https://components.dethink.co.uk/r/input.json",
    "https://components.dethink.co.uk/r/number-input.json",
    "https://components.dethink.co.uk/r/select.json",
    "https://components.dethink.co.uk/r/switch.json"
  ],
  "files": [
    {
      "path": "packages/components/src/components/inspector/inspector-path.ts",
      "type": "registry:file",
      "target": "~/components/dethink/components/inspector/inspector-path.ts",
      "content": "export type InspectorValue = Record<string, unknown>;\n\nconst unsafeKeys = new Set([\"__proto__\", \"constructor\", \"prototype\"]);\n\nfunction toPathKeys(path: string) {\n  const keys = path.split(\".\").filter(Boolean);\n\n  for (const key of keys) {\n    if (unsafeKeys.has(key)) {\n      throw new Error(`Inspector path \"${path}\" uses a reserved key.`);\n    }\n  }\n\n  return keys;\n}\n\nfunction isContainer(value: unknown): value is Record<string, unknown> {\n  return value !== null && typeof value === \"object\";\n}\n\n/** Reads a dot-notation path such as `layout.width` or `points.0.x`. */\nexport function getInspectorValue(source: unknown, path: string): unknown {\n  let current = source;\n\n  for (const key of toPathKeys(path)) {\n    if (!isContainer(current)) {\n      return undefined;\n    }\n\n    current = current[key];\n  }\n\n  return current;\n}\n\nfunction setAtKeys(source: unknown, keys: string[], value: unknown): unknown {\n  const [key, ...rest] = keys;\n\n  if (key === undefined) {\n    return value;\n  }\n\n  const container = isContainer(source) ? source : {};\n  const current = container[key];\n  const next = setAtKeys(current, rest, value);\n\n  if (isContainer(source) && Object.is(current, next)) {\n    return source;\n  }\n\n  if (Array.isArray(container)) {\n    const copy = [...container];\n    copy[Number(key)] = next;\n    return copy;\n  }\n\n  return { ...container, [key]: next };\n}\n\n/**\n * Returns a copy of `source` with `value` written at a dot-notation path.\n * Untouched branches keep their identity; missing branches become objects.\n */\nexport function setInspectorValue<T extends InspectorValue>(\n  source: T,\n  path: string,\n  value: unknown,\n): T {\n  const keys = toPathKeys(path);\n\n  if (keys.length === 0) {\n    return source;\n  }\n\n  return setAtKeys(source, keys, value) as T;\n}\n"
    },
    {
      "path": "packages/components/src/components/inspector/inspector.tsx",
      "type": "registry:file",
      "target": "~/components/dethink/components/inspector/inspector.tsx",
      "content": "import {\n  createContext,\n  createElement,\n  forwardRef,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  version as reactVersion,\n  type HTMLAttributes,\n  type ReactNode,\n} from \"react\";\nimport { cn } from \"../../utils/cn\";\nimport { EmptyState } from \"../empty-state\";\nimport {\n  getInspectorValue,\n  setInspectorValue,\n  type InspectorValue,\n} from \"./inspector-path\";\n\nexport type InspectorHeadingLevel = 2 | 3 | 4 | 5 | 6;\n\nexport interface InspectorChangeDetails {\n  /** Dot-notation path of the property that changed. */\n  path: string;\n  /** The committed value for that path. */\n  value: unknown;\n}\n\nexport interface InspectorProps extends Omit<\n  HTMLAttributes<HTMLDivElement>,\n  \"defaultValue\" | \"onChange\"\n> {\n  children?: ReactNode;\n  disabled?: boolean;\n  /** Rendered instead of the sections when `value` is null or undefined. */\n  emptyState?: ReactNode;\n  onValueChange?: (\n    next: InspectorValue,\n    details: InspectorChangeDetails,\n  ) => void;\n  readOnly?: boolean;\n  /**\n   * Identifies the selected object. Uncommitted drafts are discarded when it\n   * changes. Defaults to the `value` object's identity, so pass a stable id\n   * when `value` is rebuilt on renders that do not change the selection.\n   */\n  selectionKey?: string | number;\n  value: InspectorValue | null | undefined;\n}\n\nexport interface InspectorSectionProps extends Omit<\n  HTMLAttributes<HTMLElement>,\n  \"title\"\n> {\n  /** Controls rendered beside the heading, outside the toggle button. */\n  actions?: ReactNode;\n  children?: ReactNode;\n  collapsible?: boolean;\n  defaultOpen?: boolean;\n  description?: ReactNode;\n  headingLevel?: InspectorHeadingLevel;\n  onOpenChange?: (open: boolean) => void;\n  open?: boolean;\n  title: ReactNode;\n}\n\nexport interface InspectorPropertyProps extends Omit<\n  HTMLAttributes<HTMLDivElement>,\n  \"children\"\n> {\n  children?: ReactNode;\n  description?: ReactNode;\n  disabled?: boolean;\n  /** Explains why the property is disabled or read-only. */\n  disabledReason?: ReactNode;\n  /** Consumer-supplied error. Takes precedence over errors a control reports. */\n  error?: ReactNode;\n  label: ReactNode;\n  path: string;\n  readOnly?: boolean;\n}\n\nexport interface InspectorPropertyContextValue {\n  controlId: string;\n  describedBy: string | undefined;\n  disabled: boolean;\n  invalid: boolean;\n  labelId: string;\n  path: string;\n  readOnly: boolean;\n  /** Identity of the selected object; changes when the selection changes. */\n  selection: unknown;\n  /** Controls call this with a message to reject a draft, or null to clear it. */\n  reportError: (message: string | null) => void;\n  setValue: (value: unknown) => void;\n  value: unknown;\n}\n\ntype InspectorContextValue = {\n  commit: (path: string, value: unknown) => void;\n  disabled: boolean;\n  readOnly: boolean;\n  selection: unknown;\n  value: InspectorValue;\n};\n\nconst useIsomorphicLayoutEffect =\n  typeof window !== \"undefined\" ? useLayoutEffect : useEffect;\n\n// React 18 serializes unknown attributes; React 19 supports boolean inert.\nconst inertAttribute = (\n  Number.parseInt(reactVersion, 10) < 19 ? \"\" : true\n) as true;\n\nconst InspectorContext = createContext<InspectorContextValue | null>(null);\nconst InspectorPropertyContext =\n  createContext<InspectorPropertyContextValue | null>(null);\n\nconst inspectorBaseClasses =\n  \"@container/inspector grid min-w-0 content-start text-sm text-foreground\";\n\nconst inspectorSectionClasses =\n  \"grid min-w-0 border-b border-border last:border-b-0\";\n\nconst inspectorSectionHeaderClasses =\n  \"flex min-h-10 min-w-0 items-center gap-[var(--dt-space-2)] px-[var(--dt-space-3)]\";\n\nconst inspectorSectionHeadingClasses =\n  \"m-0 flex min-w-0 flex-1 text-xs font-semibold leading-5 text-foreground\";\n\nconst inspectorSectionTriggerClasses =\n  \"-mx-[var(--dt-space-1)] flex min-h-8 min-w-0 flex-1 items-center gap-[var(--dt-space-1-5)] rounded-sm px-[var(--dt-space-1)] text-start outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring [&[aria-expanded=false]_[data-slot=inspector-section-chevron]]:-rotate-90 rtl:[&[aria-expanded=false]_[data-slot=inspector-section-chevron]]:rotate-90\";\n\nconst inspectorSectionChevronClasses =\n  \"size-3.5 shrink-0 text-muted-foreground motion-safe:transition-transform motion-safe:duration-[var(--dt-motion-fast)] motion-safe:ease-control\";\n\nconst inspectorSectionActionsClasses =\n  \"flex shrink-0 items-center gap-[var(--dt-space-1)]\";\n\nconst inspectorSectionDescriptionClasses =\n  \"-mt-[var(--dt-space-1)] px-[var(--dt-space-3)] pb-[var(--dt-space-2)] text-xs leading-5 text-muted-foreground\";\n\n// Animates between 0fr and 1fr rows so the content can collapse to its\n// intrinsic height without measuring it.\nconst inspectorSectionRegionClasses =\n  \"grid grid-rows-[1fr] data-[state=closed]:grid-rows-[0fr] motion-safe:transition-[grid-template-rows] motion-safe:duration-[var(--dt-motion-fast)] motion-safe:ease-control\";\n\nconst inspectorSectionContentClasses =\n  \"grid min-h-0 min-w-0 gap-[var(--dt-density-gap)] overflow-hidden px-[var(--dt-space-3)] data-[state=open]:pb-[var(--dt-space-3)]\";\n\nconst inspectorPropertyClasses =\n  \"grid min-w-0 grid-cols-1 items-center gap-x-[var(--dt-density-gap)] gap-y-[var(--dt-space-1)] @min-[17rem]/inspector:grid-cols-[minmax(0,2fr)_minmax(0,3fr)] data-[disabled=true]:opacity-60\";\n\nconst inspectorPropertyLabelClasses =\n  \"min-w-0 truncate text-xs leading-5 text-muted-foreground data-[invalid=true]:text-destructive\";\n\nconst inspectorPropertyControlClasses = \"min-w-0\";\n\nconst inspectorPropertyMessageClasses =\n  \"min-w-0 text-xs leading-5 @min-[17rem]/inspector:col-start-2\";\n\nexport function inspectorClassNames({\n  className,\n}: Pick<InspectorProps, \"className\"> = {}) {\n  return cn(inspectorBaseClasses, className);\n}\n\nexport function inspectorSectionClassNames({\n  className,\n}: Pick<InspectorSectionProps, \"className\"> = {}) {\n  return cn(inspectorSectionClasses, className);\n}\n\nexport function inspectorPropertyClassNames({\n  className,\n}: Pick<InspectorPropertyProps, \"className\"> = {}) {\n  return cn(inspectorPropertyClasses, className);\n}\n\nfunction useInspectorContext(component: string) {\n  const context = useContext(InspectorContext);\n\n  if (!context) {\n    throw new Error(`${component} must be rendered inside <Inspector>.`);\n  }\n\n  return context;\n}\n\n/**\n * Wires a custom control to the surrounding InspectorProperty: ids for\n * labelling, the current value, and a setter that commits the change.\n */\nexport function useInspectorProperty() {\n  const context = useContext(InspectorPropertyContext);\n\n  if (!context) {\n    throw new Error(\n      \"Inspector controls must be rendered inside <InspectorProperty>.\",\n    );\n  }\n\n  return context;\n}\n\nfunction toTitle(content: ReactNode) {\n  return typeof content === \"string\" || typeof content === \"number\"\n    ? String(content)\n    : undefined;\n}\n\nfunction ChevronIcon() {\n  return (\n    <svg\n      aria-hidden=\"true\"\n      data-slot=\"inspector-section-chevron\"\n      className={inspectorSectionChevronClasses}\n      fill=\"none\"\n      viewBox=\"0 0 16 16\"\n    >\n      <path\n        d=\"m4 6 4 4 4-4\"\n        stroke=\"currentColor\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        strokeWidth=\"1.5\"\n      />\n    </svg>\n  );\n}\n\nexport const Inspector = forwardRef<HTMLDivElement, InspectorProps>(\n  (\n    {\n      children,\n      className,\n      disabled = false,\n      emptyState,\n      onValueChange,\n      readOnly = false,\n      selectionKey,\n      value,\n      ...props\n    },\n    ref,\n  ) => {\n    // Several controls can commit before the parent re-renders (for example\n    // both axes of a vector), so commits in the same task build on each other.\n    // Once that task ends, the working copy falls back to the rendered value:\n    // a parent that rejected the change never re-renders, and later edits\n    // must not resurrect it.\n    const renderedValue = useRef<InspectorValue>(value ?? {});\n    const latestValue = useRef<InspectorValue>(value ?? {});\n    const resyncScheduled = useRef(false);\n\n    useIsomorphicLayoutEffect(() => {\n      renderedValue.current = value ?? {};\n      latestValue.current = value ?? {};\n    });\n\n    const commit = useCallback(\n      (path: string, nextPropertyValue: unknown) => {\n        const next = setInspectorValue(\n          latestValue.current,\n          path,\n          nextPropertyValue,\n        );\n\n        if (next === latestValue.current) {\n          return;\n        }\n\n        latestValue.current = next;\n\n        if (!resyncScheduled.current) {\n          resyncScheduled.current = true;\n          queueMicrotask(() => {\n            resyncScheduled.current = false;\n            latestValue.current = renderedValue.current;\n          });\n        }\n\n        onValueChange?.(next, { path, value: nextPropertyValue });\n      },\n      [onValueChange],\n    );\n\n    const contextValue = useMemo<InspectorContextValue>(\n      () => ({\n        commit,\n        disabled,\n        readOnly,\n        selection: selectionKey ?? value,\n        value: value ?? {},\n      }),\n      [commit, disabled, readOnly, selectionKey, value],\n    );\n    const empty = value === null || value === undefined;\n\n    return (\n      <div\n        {...props}\n        ref={ref}\n        data-slot=\"inspector\"\n        data-empty={empty ? \"true\" : undefined}\n        data-disabled={disabled ? \"true\" : undefined}\n        data-readonly={readOnly ? \"true\" : undefined}\n        className={inspectorClassNames({ className })}\n      >\n        {empty ? (\n          (emptyState ?? (\n            <EmptyState\n              variant=\"compact\"\n              title=\"Nothing selected\"\n              description=\"Select an item to edit its properties.\"\n            />\n          ))\n        ) : (\n          <InspectorContext.Provider value={contextValue}>\n            {children}\n          </InspectorContext.Provider>\n        )}\n      </div>\n    );\n  },\n);\n\nInspector.displayName = \"Inspector\";\n\nexport const InspectorSection = forwardRef<HTMLElement, InspectorSectionProps>(\n  (\n    {\n      actions,\n      children,\n      className,\n      collapsible = true,\n      defaultOpen = true,\n      description,\n      headingLevel = 3,\n      onOpenChange,\n      open,\n      title,\n      ...props\n    },\n    ref,\n  ) => {\n    const baseId = useId();\n    const headingId = `${baseId}-heading`;\n    const regionId = `${baseId}-content`;\n    const descriptionId = `${baseId}-description`;\n    const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);\n    const resolvedOpen = !collapsible || (open ?? uncontrolledOpen);\n    const state = resolvedOpen ? \"open\" : \"closed\";\n\n    const toggle = () => {\n      const next = !resolvedOpen;\n\n      if (open === undefined) {\n        setUncontrolledOpen(next);\n      }\n\n      onOpenChange?.(next);\n    };\n\n    return (\n      <section\n        {...props}\n        ref={ref}\n        aria-labelledby={headingId}\n        data-slot=\"inspector-section\"\n        data-state={state}\n        className={inspectorSectionClassNames({ className })}\n      >\n        <div\n          data-slot=\"inspector-section-header\"\n          className={inspectorSectionHeaderClasses}\n        >\n          {createElement(\n            `h${headingLevel}`,\n            {\n              id: headingId,\n              \"data-slot\": \"inspector-section-title\",\n              className: inspectorSectionHeadingClasses,\n            },\n            collapsible ? (\n              <button\n                type=\"button\"\n                aria-controls={regionId}\n                aria-expanded={resolvedOpen}\n                data-slot=\"inspector-section-trigger\"\n                className={inspectorSectionTriggerClasses}\n                onClick={toggle}\n              >\n                <ChevronIcon />\n                <span className=\"min-w-0 truncate\">{title}</span>\n              </button>\n            ) : (\n              <span className=\"min-w-0 truncate\">{title}</span>\n            ),\n          )}\n          {actions ? (\n            <div\n              data-slot=\"inspector-section-actions\"\n              className={inspectorSectionActionsClasses}\n            >\n              {actions}\n            </div>\n          ) : null}\n        </div>\n        {description && resolvedOpen ? (\n          <p\n            id={descriptionId}\n            data-slot=\"inspector-section-description\"\n            className={inspectorSectionDescriptionClasses}\n          >\n            {description}\n          </p>\n        ) : null}\n        <div\n          id={regionId}\n          data-slot=\"inspector-section-region\"\n          data-state={state}\n          className={inspectorSectionRegionClasses}\n          // Spread so React 18 types, which lack inert, still accept it.\n          {...{ inert: resolvedOpen ? undefined : inertAttribute }}\n        >\n          <div\n            role=\"group\"\n            aria-labelledby={headingId}\n            aria-describedby={\n              description && resolvedOpen ? descriptionId : undefined\n            }\n            data-slot=\"inspector-section-content\"\n            data-state={state}\n            className={inspectorSectionContentClasses}\n          >\n            {children}\n          </div>\n        </div>\n      </section>\n    );\n  },\n);\n\nInspectorSection.displayName = \"InspectorSection\";\n\nexport const InspectorProperty = forwardRef<\n  HTMLDivElement,\n  InspectorPropertyProps\n>(\n  (\n    {\n      children,\n      className,\n      description,\n      disabled,\n      disabledReason,\n      error,\n      label,\n      path,\n      readOnly,\n      ...props\n    },\n    ref,\n  ) => {\n    const inspector = useInspectorContext(\"InspectorProperty\");\n    const baseId = useId();\n    const controlId = `${baseId}-control`;\n    const labelId = `${baseId}-label`;\n    const descriptionId = `${baseId}-description`;\n    const reasonId = `${baseId}-reason`;\n    const errorId = `${baseId}-error`;\n    const [reportedError, setReportedError] = useState<string | null>(null);\n    const resolvedDisabled = inspector.disabled || Boolean(disabled);\n    const resolvedReadOnly = inspector.readOnly || Boolean(readOnly);\n    const resolvedError = error ?? reportedError;\n    const invalid = Boolean(resolvedError);\n    const reason = resolvedDisabled || resolvedReadOnly ? disabledReason : null;\n    const describedBy =\n      [\n        description ? descriptionId : null,\n        reason ? reasonId : null,\n        invalid ? errorId : null,\n      ]\n        .filter(Boolean)\n        .join(\" \") || undefined;\n    const { commit } = inspector;\n    const value = getInspectorValue(inspector.value, path);\n\n    const setValue = useCallback(\n      (nextValue: unknown) => commit(path, nextValue),\n      [commit, path],\n    );\n\n    const contextValue = useMemo<InspectorPropertyContextValue>(\n      () => ({\n        controlId,\n        describedBy,\n        disabled: resolvedDisabled,\n        invalid,\n        labelId,\n        path,\n        readOnly: resolvedReadOnly,\n        reportError: setReportedError,\n        selection: inspector.selection,\n        setValue,\n        value,\n      }),\n      [\n        controlId,\n        describedBy,\n        invalid,\n        labelId,\n        path,\n        resolvedDisabled,\n        resolvedReadOnly,\n        inspector.selection,\n        setValue,\n        value,\n      ],\n    );\n\n    return (\n      <div\n        {...props}\n        ref={ref}\n        data-slot=\"inspector-property\"\n        data-path={path}\n        data-disabled={resolvedDisabled ? \"true\" : undefined}\n        data-readonly={resolvedReadOnly ? \"true\" : undefined}\n        data-invalid={invalid ? \"true\" : undefined}\n        className={inspectorPropertyClassNames({ className })}\n      >\n        <label\n          id={labelId}\n          htmlFor={controlId}\n          title={toTitle(label)}\n          data-slot=\"inspector-property-label\"\n          data-invalid={invalid ? \"true\" : undefined}\n          className={inspectorPropertyLabelClasses}\n        >\n          {label}\n        </label>\n        <div\n          data-slot=\"inspector-property-control\"\n          className={inspectorPropertyControlClasses}\n        >\n          <InspectorPropertyContext.Provider value={contextValue}>\n            {children}\n          </InspectorPropertyContext.Provider>\n        </div>\n        {description ? (\n          <p\n            id={descriptionId}\n            data-slot=\"inspector-property-description\"\n            className={cn(\n              inspectorPropertyMessageClasses,\n              \"text-muted-foreground\",\n            )}\n          >\n            {description}\n          </p>\n        ) : null}\n        {reason ? (\n          <p\n            id={reasonId}\n            data-slot=\"inspector-property-reason\"\n            className={cn(\n              inspectorPropertyMessageClasses,\n              \"text-muted-foreground\",\n            )}\n          >\n            {reason}\n          </p>\n        ) : null}\n        {invalid ? (\n          <p\n            id={errorId}\n            data-slot=\"inspector-property-error\"\n            className={cn(\n              inspectorPropertyMessageClasses,\n              \"text-destructive font-medium\",\n            )}\n          >\n            {resolvedError}\n          </p>\n        ) : null}\n      </div>\n    );\n  },\n);\n\nInspectorProperty.displayName = \"InspectorProperty\";\n"
    },
    {
      "path": "packages/components/src/components/inspector/inspector-controls.tsx",
      "type": "registry:file",
      "target": "~/components/dethink/components/inspector/inspector-controls.tsx",
      "content": "import {\n  forwardRef,\n  useEffect,\n  useState,\n  type ChangeEvent,\n  type FocusEvent,\n  type KeyboardEvent,\n  type ReactNode,\n} from \"react\";\nimport { cn } from \"../../utils/cn\";\nimport { Input, type InputProps } from \"../input\";\nimport { NumberInput, type NumberInputProps } from \"../number-input\";\nimport { Select, SelectItem } from \"../select\";\nimport { Switch, type SwitchProps } from \"../switch\";\nimport { useInspectorProperty } from \"./inspector\";\n\nexport interface InspectorNumberProps extends Omit<\n  NumberInputProps,\n  | \"controlSize\"\n  | \"defaultValue\"\n  | \"id\"\n  | \"max\"\n  | \"min\"\n  | \"onChange\"\n  | \"step\"\n  | \"type\"\n  | \"value\"\n> {\n  max?: number;\n  min?: number;\n  /** Decimal places kept on commit. Defaults to the decimals in `step`. */\n  precision?: number;\n  step?: number;\n  /** Short unit shown inside the field, such as px, %, or deg. */\n  unit?: string;\n}\n\nexport interface InspectorTextProps extends Omit<\n  InputProps,\n  \"controlSize\" | \"defaultValue\" | \"id\" | \"onChange\" | \"value\"\n> {\n  /** Return a message to reject the draft and keep the previous value. */\n  validate?: (value: string) => string | null | undefined;\n}\n\nexport interface InspectorSwitchProps extends Omit<\n  SwitchProps,\n  | \"checked\"\n  | \"controlSize\"\n  | \"defaultChecked\"\n  | \"id\"\n  | \"onChange\"\n  | \"onCheckedChange\"\n> {}\n\nexport interface InspectorSelectOption {\n  disabled?: boolean;\n  label: ReactNode;\n  textValue?: string;\n  value: string;\n}\n\nexport interface InspectorSelectProps {\n  className?: string;\n  options: InspectorSelectOption[];\n  placeholder?: string;\n}\n\nconst inspectorNumberRootClasses = \"relative min-w-0\";\n\nconst inspectorNumberUnitClasses =\n  \"pointer-events-none absolute inset-y-0 end-[var(--dt-space-2-5)] flex items-center text-xs text-muted-foreground\";\n\nfunction countDecimals(value: number) {\n  const [, decimals = \"\"] = String(value).split(\".\");\n  return decimals.length;\n}\n\nfunction roundWithin(\n  value: number,\n  precision: number,\n  min?: number,\n  max?: number,\n) {\n  // Round first, then step back inside any bound the rounding crossed.\n  const step = 10 ** -precision;\n  let next = roundTo(clamp(value, min, max), precision);\n\n  if (max !== undefined && next > max) {\n    next = roundTo(Math.floor(max / step) * step, precision);\n  }\n\n  if (min !== undefined && next < min) {\n    next = roundTo(Math.ceil(min / step) * step, precision);\n  }\n\n  // A range narrower than the precision has no representable value; honor\n  // the bounds over the precision.\n  return clamp(next, min, max);\n}\n\nfunction clamp(value: number, min?: number, max?: number) {\n  let next = value;\n\n  if (min !== undefined) next = Math.max(min, next);\n  if (max !== undefined) next = Math.min(max, next);\n\n  return next;\n}\n\nfunction roundTo(value: number, precision: number) {\n  return Number(value.toFixed(precision));\n}\n\nfunction stripUnit(draft: string, unit?: string) {\n  const trimmed = draft.trim();\n\n  if (unit && trimmed.toLowerCase().endsWith(unit.toLowerCase())) {\n    return trimmed.slice(0, -unit.length).trim();\n  }\n\n  return trimmed;\n}\n\n/**\n * Keeps an uncommitted draft while the user types. Committing, reverting, or\n * an external value change all return the field to the committed value.\n */\nfunction useDraft(committedText: string) {\n  const { disabled, path, readOnly, reportError, selection } =\n    useInspectorProperty();\n  const [draft, setDraft] = useState<string | null>(null);\n  const locked = disabled || readOnly;\n  const bindingKey = `${path}\\u0000${committedText}\\u0000${locked}`;\n  const [bound, setBound] = useState({ key: bindingKey, selection });\n\n  // A new selection, an external change, or locking the property discards the\n  // stale draft before it can be committed into the newly bound value.\n  // Selection is tracked apart from the text so objects with equal values do\n  // not share a draft.\n  if (bound.key !== bindingKey || !Object.is(bound.selection, selection)) {\n    setBound({ key: bindingKey, selection });\n    setDraft(null);\n  }\n\n  useEffect(() => {\n    reportError(null);\n  }, [bindingKey, reportError, selection]);\n\n  return {\n    display: draft ?? committedText,\n    // A property that turns read-only or disabled mid-edit keeps no draft to\n    // commit, even if a blur or key event lands before the reset renders.\n    draft: locked ? null : draft,\n    reset: () => setDraft(null),\n    setDraft,\n  };\n}\n\nexport const InspectorNumber = forwardRef<\n  HTMLInputElement,\n  InspectorNumberProps\n>(\n  (\n    {\n      className,\n      max,\n      min,\n      onBlur,\n      onKeyDown,\n      precision,\n      step = 1,\n      style,\n      unit,\n      ...props\n    },\n    ref,\n  ) => {\n    const property = useInspectorProperty();\n    const committed =\n      typeof property.value === \"number\" && Number.isFinite(property.value)\n        ? property.value\n        : undefined;\n    const resolvedPrecision = precision ?? countDecimals(step);\n    const format = (value: number) => String(roundTo(value, resolvedPrecision));\n    const { display, draft, reset, setDraft } = useDraft(\n      committed === undefined ? \"\" : format(committed),\n    );\n\n    const apply = (value: number) => {\n      if (property.readOnly || property.disabled) {\n        return;\n      }\n\n      const next = roundWithin(value, resolvedPrecision, min, max);\n\n      reset();\n      property.reportError(null);\n\n      if (next !== committed) {\n        property.setValue(next);\n      }\n    };\n\n    const commitDraft = () => {\n      if (draft === null) {\n        return;\n      }\n\n      const text = stripUnit(draft, unit);\n\n      if (text === \"\" && committed === undefined) {\n        reset();\n        property.reportError(null);\n        return;\n      }\n\n      const parsed = text === \"\" ? Number.NaN : Number(text);\n\n      if (!Number.isFinite(parsed)) {\n        property.reportError(\"Enter a number.\");\n        return;\n      }\n\n      apply(parsed);\n    };\n\n    const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {\n      onKeyDown?.(event);\n\n      if (event.defaultPrevented || property.readOnly) {\n        return;\n      }\n\n      if (event.key === \"Enter\") {\n        commitDraft();\n        return;\n      }\n\n      if (event.key === \"Escape\") {\n        if (draft !== null) {\n          event.preventDefault();\n          reset();\n          property.reportError(null);\n        }\n        return;\n      }\n\n      if (event.key === \"Home\" && min !== undefined) {\n        event.preventDefault();\n        apply(min);\n        return;\n      }\n\n      if (event.key === \"End\" && max !== undefined) {\n        event.preventDefault();\n        apply(max);\n        return;\n      }\n\n      if (event.key === \"ArrowUp\" || event.key === \"ArrowDown\") {\n        event.preventDefault();\n        const drafted =\n          draft === null ? Number.NaN : Number(stripUnit(draft, unit));\n        const base = Number.isFinite(drafted)\n          ? drafted\n          : (committed ?? min ?? 0);\n\n        apply(base + (event.key === \"ArrowUp\" ? step : -step));\n      }\n    };\n\n    const handleBlur = (event: FocusEvent<HTMLInputElement>) => {\n      onBlur?.(event);\n      commitDraft();\n    };\n\n    return (\n      <div\n        data-slot=\"inspector-number\"\n        className={cn(inspectorNumberRootClasses, className)}\n      >\n        <NumberInput\n          {...props}\n          ref={ref}\n          id={property.controlId}\n          role=\"spinbutton\"\n          aria-labelledby={property.labelId}\n          aria-describedby={property.describedBy}\n          aria-valuemin={min}\n          aria-valuemax={max}\n          aria-valuenow={committed}\n          aria-valuetext={\n            committed !== undefined && unit\n              ? `${format(committed)} ${unit}`\n              : undefined\n          }\n          autoComplete=\"off\"\n          controlSize=\"sm\"\n          disabled={property.disabled}\n          invalid={property.invalid}\n          readOnly={property.readOnly}\n          value={display}\n          style={\n            unit\n              ? {\n                  paddingInlineEnd: `calc(${unit.length}ch + var(--dt-space-4))`,\n                  ...style,\n                }\n              : style\n          }\n          onBlur={handleBlur}\n          onChange={(event: ChangeEvent<HTMLInputElement>) =>\n            setDraft(event.target.value)\n          }\n          onKeyDown={handleKeyDown}\n        />\n        {unit ? (\n          <span\n            aria-hidden=\"true\"\n            data-slot=\"inspector-number-unit\"\n            className={inspectorNumberUnitClasses}\n          >\n            {unit}\n          </span>\n        ) : null}\n      </div>\n    );\n  },\n);\n\nInspectorNumber.displayName = \"InspectorNumber\";\n\nexport const InspectorText = forwardRef<HTMLInputElement, InspectorTextProps>(\n  ({ onBlur, onKeyDown, validate, ...props }, ref) => {\n    const property = useInspectorProperty();\n    const committed = typeof property.value === \"string\" ? property.value : \"\";\n    const { display, draft, reset, setDraft } = useDraft(committed);\n\n    const commitDraft = () => {\n      if (draft === null) {\n        return;\n      }\n\n      const message = validate?.(draft);\n\n      if (message) {\n        property.reportError(message);\n        return;\n      }\n\n      reset();\n      property.reportError(null);\n\n      if (draft !== committed) {\n        property.setValue(draft);\n      }\n    };\n\n    return (\n      <Input\n        {...props}\n        ref={ref}\n        id={property.controlId}\n        aria-labelledby={property.labelId}\n        aria-describedby={property.describedBy}\n        autoComplete=\"off\"\n        controlSize=\"sm\"\n        disabled={property.disabled}\n        invalid={property.invalid}\n        readOnly={property.readOnly}\n        value={display}\n        onBlur={(event) => {\n          onBlur?.(event);\n          commitDraft();\n        }}\n        onChange={(event) => setDraft(event.target.value)}\n        onKeyDown={(event) => {\n          onKeyDown?.(event);\n\n          if (event.defaultPrevented || property.readOnly) {\n            return;\n          }\n\n          if (event.key === \"Enter\") {\n            commitDraft();\n          } else if (event.key === \"Escape\" && draft !== null) {\n            event.preventDefault();\n            reset();\n            property.reportError(null);\n          }\n        }}\n      />\n    );\n  },\n);\n\nInspectorText.displayName = \"InspectorText\";\n\nexport const InspectorSwitch = forwardRef<\n  HTMLInputElement,\n  InspectorSwitchProps\n>((props, ref) => {\n  const property = useInspectorProperty();\n\n  return (\n    <Switch\n      {...props}\n      ref={ref}\n      id={property.controlId}\n      aria-labelledby={property.labelId}\n      aria-describedby={property.describedBy}\n      checked={property.value === true}\n      controlSize=\"sm\"\n      disabled={property.disabled}\n      invalid={property.invalid}\n      readOnly={property.readOnly}\n      onCheckedChange={(checked) => property.setValue(checked)}\n    />\n  );\n});\n\nInspectorSwitch.displayName = \"InspectorSwitch\";\n\nexport function InspectorSelect({\n  className,\n  options,\n  placeholder = \"Select…\",\n}: InspectorSelectProps) {\n  const property = useInspectorProperty();\n  const value = typeof property.value === \"string\" ? property.value : undefined;\n\n  return (\n    <Select\n      // Select treats an undefined value as uncontrolled, so remount it when\n      // the property is cleared rather than keeping a stale selection.\n      key={value === undefined ? \"empty\" : \"set\"}\n      id={property.controlId}\n      aria-labelledby={property.labelId}\n      aria-describedby={property.describedBy}\n      className={cn(\"min-w-0\", className)}\n      controlSize=\"sm\"\n      disabled={property.disabled}\n      invalid={property.invalid}\n      placeholder={placeholder}\n      readOnly={property.readOnly}\n      value={value}\n      onValueChange={(next) => property.setValue(next)}\n    >\n      {options.map((option) => (\n        <SelectItem\n          key={option.value}\n          value={option.value}\n          disabled={option.disabled}\n          textValue={option.textValue}\n        >\n          {option.label}\n        </SelectItem>\n      ))}\n    </Select>\n  );\n}\n"
    },
    {
      "path": "packages/components/src/components/inspector/index.ts",
      "type": "registry:file",
      "target": "~/components/dethink/components/inspector/index.ts",
      "content": "export {\n  Inspector,\n  InspectorProperty,\n  InspectorSection,\n  inspectorClassNames,\n  inspectorPropertyClassNames,\n  inspectorSectionClassNames,\n  useInspectorProperty,\n  type InspectorChangeDetails,\n  type InspectorHeadingLevel,\n  type InspectorPropertyContextValue,\n  type InspectorPropertyProps,\n  type InspectorProps,\n  type InspectorSectionProps,\n} from \"./inspector\";\nexport {\n  InspectorNumber,\n  InspectorSelect,\n  InspectorSwitch,\n  InspectorText,\n  type InspectorNumberProps,\n  type InspectorSelectOption,\n  type InspectorSelectProps,\n  type InspectorSwitchProps,\n  type InspectorTextProps,\n} from \"./inspector-controls\";\nexport {\n  getInspectorValue,\n  setInspectorValue,\n  type InspectorValue,\n} from \"./inspector-path\";\n"
    },
    {
      "path": "packages/components/src/utils/cn.ts",
      "type": "registry:file",
      "target": "~/components/dethink/utils/cn.ts",
      "content": "import { clsx, type ClassValue } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"
    }
  ]
}
