{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "question-flow",
  "type": "registry:block",
  "title": "Question Flow",
  "description": "Multi-step guided questions with branching.",
  "dependencies": [
    "lucide-react",
    "zod"
  ],
  "registryDependencies": [
    "button",
    "separator"
  ],
  "files": [
    {
      "path": "components/tool-ui/question-flow/_adapter.tsx",
      "type": "registry:component",
      "target": "components/tool-ui/question-flow/_adapter.tsx",
      "content": "/**\n * Adapter: UI and utility re-exports for copy-standalone portability.\n *\n * When copying this component to another project, update these imports\n * to match your project's paths:\n *\n *   cn        → Your Tailwind merge utility (e.g., \"@/lib/utils\", \"~/lib/cn\")\n *   Button    → shadcn/ui Button\n *   Separator → shadcn/ui Separator\n */\n\nexport { cn } from \"@/lib/utils\";\nexport { Button } from \"@/components/ui/button\";\nexport { Separator } from \"@/components/ui/separator\";\n"
    },
    {
      "path": "components/tool-ui/question-flow/index.tsx",
      "type": "registry:component",
      "target": "components/tool-ui/question-flow/index.tsx",
      "content": "export { QuestionFlow } from \"./question-flow\";\nexport {\n  type SerializableQuestionFlow,\n  type SerializableProgressiveMode,\n  type SerializableUpfrontMode,\n  type SerializableReceiptMode,\n  type QuestionFlowProps,\n  type QuestionFlowProgressiveProps,\n  type QuestionFlowUpfrontProps,\n  type QuestionFlowReceiptProps,\n  type QuestionFlowOption,\n  type QuestionFlowStepDefinition,\n  type QuestionFlowChoice,\n  type QuestionFlowSummaryItem,\n} from \"./schema\";\n"
    },
    {
      "path": "components/tool-ui/question-flow/question-flow.tsx",
      "type": "registry:component",
      "target": "components/tool-ui/question-flow/question-flow.tsx",
      "content": "\"use client\";\n\nimport {\n  useMemo,\n  useState,\n  useCallback,\n  useRef,\n  useEffect,\n  Fragment,\n} from \"react\";\nimport type { KeyboardEvent } from \"react\";\nimport type {\n  QuestionFlowProps,\n  QuestionFlowProgressiveProps,\n  QuestionFlowUpfrontProps,\n  QuestionFlowReceiptProps,\n  QuestionFlowOption,\n} from \"./schema\";\nimport { cn, Button, Separator } from \"./_adapter\";\nimport { Check, ChevronLeft } from \"lucide-react\";\n\ninterface SelectionIndicatorProps {\n  mode: \"single\" | \"multi\";\n  isSelected: boolean;\n  disabled?: boolean;\n}\n\ninterface ProgressBarProps {\n  current: number;\n  total: number;\n}\n\nfunction ProgressBar({ current, total }: ProgressBarProps) {\n  return (\n    <div\n      className=\"flex h-1.5 gap-1\"\n      role=\"progressbar\"\n      aria-valuenow={current}\n      aria-valuemin={1}\n      aria-valuemax={total}\n    >\n      {Array.from({ length: total }).map((_, i) => (\n        <div\n          key={i}\n          className=\"relative flex-1 overflow-hidden rounded-full bg-muted\"\n        >\n          <div\n            className={cn(\n              \"absolute inset-0 origin-left rounded-full bg-primary\",\n              \"motion-safe:transition-transform motion-safe:duration-300 motion-safe:ease-[var(--cubic-ease-in-out)]\",\n              i < current ? \"scale-x-100\" : \"scale-x-0\",\n            )}\n          />\n        </div>\n      ))}\n    </div>\n  );\n}\n\nfunction SelectionIndicator({\n  mode,\n  isSelected,\n  disabled,\n}: SelectionIndicatorProps) {\n  const shape = mode === \"single\" ? \"rounded-full\" : \"rounded\";\n\n  return (\n    <div\n      className={cn(\n        \"flex size-4 shrink-0 items-center justify-center border-2\",\n        \"motion-safe:transition-colors motion-safe:duration-200\",\n        shape,\n        isSelected && [\n          \"border-primary bg-primary text-primary-foreground\",\n          \"motion-safe:animate-in motion-safe:fade-in motion-safe:zoom-in-75 motion-safe:duration-300 motion-safe:ease-out\",\n        ],\n        !isSelected && \"border-muted-foreground/50\",\n        disabled && \"opacity-50\",\n      )}\n    >\n      {mode === \"multi\" && isSelected && (\n        <Check\n          className=\"size-3 motion-safe:animate-in motion-safe:fade-in motion-safe:zoom-in-75 motion-safe:delay-75 motion-safe:duration-200 motion-safe:fill-mode-both\"\n          strokeWidth={3}\n        />\n      )}\n      {mode === \"single\" && isSelected && (\n        <span className=\"size-2 rounded-full bg-current motion-safe:animate-in motion-safe:fade-in motion-safe:zoom-in-75 motion-safe:duration-300 motion-safe:ease-out\" />\n      )}\n    </div>\n  );\n}\n\ninterface OptionItemProps {\n  option: QuestionFlowOption;\n  isSelected: boolean;\n  isDisabled: boolean;\n  selectionMode: \"single\" | \"multi\";\n  isFirst: boolean;\n  isLast: boolean;\n  onToggle: () => void;\n  tabIndex?: number;\n  onFocus?: () => void;\n  buttonRef?: (el: HTMLButtonElement | null) => void;\n}\n\nfunction OptionItem({\n  option,\n  isSelected,\n  isDisabled,\n  selectionMode,\n  isFirst,\n  isLast,\n  onToggle,\n  tabIndex,\n  onFocus,\n  buttonRef,\n}: OptionItemProps) {\n  const hasAdjacentOptions = !isFirst && !isLast;\n\n  return (\n    <Button\n      ref={buttonRef}\n      data-id={option.id}\n      variant=\"ghost\"\n      size=\"lg\"\n      role=\"option\"\n      aria-selected={isSelected}\n      onClick={onToggle}\n      onFocus={onFocus}\n      tabIndex={tabIndex}\n      disabled={isDisabled}\n      className={cn(\n        \"peer group relative h-auto min-h-[50px] w-full justify-start text-left text-sm font-medium\",\n        \"rounded-none border-0 bg-transparent px-0 py-2 text-base shadow-none transition-none hover:bg-transparent! @md/question-flow:text-sm\",\n        isFirst && \"pb-2.5\",\n        hasAdjacentOptions && \"py-2.5\",\n      )}\n    >\n      <span\n        className={cn(\n          \"bg-primary/5 absolute inset-0 -mx-3 -my-0.5 rounded-xl opacity-0 transition-opacity group-hover:opacity-100\",\n        )}\n      />\n      <div className=\"relative flex items-start gap-3\">\n        <span className=\"flex h-6 items-center\">\n          <SelectionIndicator\n            mode={selectionMode}\n            isSelected={isSelected}\n            disabled={option.disabled}\n          />\n        </span>\n        {option.icon && (\n          <span className=\"flex h-6 items-center\">{option.icon}</span>\n        )}\n        <div className=\"flex flex-col text-left\">\n          <span className=\"leading-6 text-pretty\">{option.label}</span>\n          {option.description && (\n            <span className=\"text-muted-foreground text-sm font-normal text-pretty\">\n              {option.description}\n            </span>\n          )}\n        </div>\n      </div>\n    </Button>\n  );\n}\n\nfunction QuestionFlowReceipt({\n  id,\n  choice,\n  className,\n}: QuestionFlowReceiptProps) {\n  return (\n    <div\n      className={cn(\n        \"@container/question-flow flex w-full min-w-80 max-w-md flex-col\",\n        \"text-foreground\",\n        \"motion-safe:animate-in motion-safe:fade-in motion-safe:blur-in-sm motion-safe:zoom-in-95 motion-safe:duration-300 motion-safe:ease-out motion-safe:fill-mode-both\",\n        className,\n      )}\n      data-slot=\"question-flow\"\n      data-tool-ui-id={id}\n      data-receipt=\"true\"\n      role=\"status\"\n      aria-label={choice.title}\n    >\n      <div\n        className={cn(\n          \"bg-card/60 flex w-full flex-col gap-3 rounded-2xl border px-5 py-4 shadow-xs\",\n        )}\n      >\n        <div className=\"flex items-center justify-between gap-3\">\n          <span className=\"text-base font-medium\">{choice.title}</span>\n          <span className=\"flex items-center gap-1.5 text-xs font-medium text-emerald-600 dark:text-emerald-500\">\n            <Check className=\"size-3.5\" />\n            Complete\n          </span>\n        </div>\n        <div className=\"flex flex-col\">\n          {choice.summary.map((item, index) => (\n            <Fragment key={index}>\n              {index > 0 && <Separator className=\"my-2\" />}\n              <div\n                className=\"flex flex-col gap-0.5 text-sm motion-safe:animate-in motion-safe:fade-in motion-safe:blur-in-sm motion-safe:slide-in-from-bottom-1 motion-safe:duration-300 motion-safe:ease-out motion-safe:fill-mode-both\"\n                style={{ animationDelay: `${150 + index * 75}ms` }}\n              >\n                <span className=\"text-muted-foreground\">{item.label}</span>\n                <span className=\"font-medium\">{item.value}</span>\n              </div>\n            </Fragment>\n          ))}\n        </div>\n      </div>\n    </div>\n  );\n}\n\ninterface StepBodyData {\n  stepKey: string;\n  title: string;\n  description?: string;\n  options: QuestionFlowOption[];\n  selectionMode: \"single\" | \"multi\";\n  selectedIds: Set<string>;\n}\n\nexport function getQuestionFlowStepIds(id: string, stepKey: string) {\n  const safeId = encodeURIComponent(id).replace(/%/g, \"_\");\n  const safeStepKey = encodeURIComponent(stepKey).replace(/%/g, \"_\");\n  return {\n    titleId: `${safeId}-${safeStepKey}-title`,\n    descriptionId: `${safeId}-${safeStepKey}-description`,\n  };\n}\n\ninterface StepContentProps {\n  step: number;\n  totalSteps?: number;\n  title: string;\n  description?: string;\n  options: QuestionFlowOption[];\n  selectionMode: \"single\" | \"multi\";\n  selectedIds: Set<string>;\n  onToggle: (optionId: string) => void;\n  onBack?: () => void;\n  onNext: () => void;\n  showBack: boolean;\n  isLastStep: boolean;\n  id: string;\n  className?: string;\n  stepKey?: string;\n  exitingStepData?: StepBodyData | null;\n  transitionDirection?: \"forward\" | \"backward\";\n}\n\nfunction StepBodyContent({\n  stepKey,\n  title,\n  description,\n  options,\n  selectionMode,\n  selectedIds,\n  onToggle,\n  id,\n  isExiting,\n  transitionDirection,\n}: {\n  stepKey: string;\n  title: string;\n  description?: string;\n  options: QuestionFlowOption[];\n  selectionMode: \"single\" | \"multi\";\n  selectedIds: Set<string>;\n  onToggle?: (optionId: string) => void;\n  id: string;\n  isExiting?: boolean;\n  transitionDirection?: \"forward\" | \"backward\";\n}) {\n  const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);\n  const { titleId, descriptionId } = getQuestionFlowStepIds(id, stepKey);\n\n  const optionStates = useMemo(() => {\n    return options.map((option) => {\n      const isSelected = selectedIds.has(option.id);\n      const isDisabled = option.disabled ?? false;\n      return { option, isSelected, isDisabled };\n    });\n  }, [options, selectedIds]);\n\n  const [activeIndex, setActiveIndex] = useState(() => {\n    const firstSelected = optionStates.findIndex(\n      (s) => s.isSelected && !s.isDisabled,\n    );\n    if (firstSelected >= 0) return firstSelected;\n    const firstEnabled = optionStates.findIndex((s) => !s.isDisabled);\n    return firstEnabled >= 0 ? firstEnabled : 0;\n  });\n\n  const focusOptionAt = useCallback((index: number) => {\n    const el = optionRefs.current[index];\n    if (el) el.focus();\n    setActiveIndex(index);\n  }, []);\n\n  const findNextEnabledIndex = useCallback(\n    (start: number, direction: 1 | -1) => {\n      const len = optionStates.length;\n      if (len === 0) return 0;\n      for (let s = 1; s <= len; s++) {\n        const idx = (start + direction * s + len) % len;\n        if (!optionStates[idx].isDisabled) return idx;\n      }\n      return start;\n    },\n    [optionStates],\n  );\n\n  const handleKeyDown = useCallback(\n    (e: KeyboardEvent<HTMLDivElement>) => {\n      if (optionStates.length === 0 || isExiting) return;\n\n      const key = e.key;\n\n      if (key === \"ArrowDown\") {\n        e.preventDefault();\n        focusOptionAt(findNextEnabledIndex(activeIndex, 1));\n        return;\n      }\n\n      if (key === \"ArrowUp\") {\n        e.preventDefault();\n        focusOptionAt(findNextEnabledIndex(activeIndex, -1));\n        return;\n      }\n\n      if (key === \"Home\") {\n        e.preventDefault();\n        const first = optionStates.findIndex((s) => !s.isDisabled);\n        focusOptionAt(first >= 0 ? first : 0);\n        return;\n      }\n\n      if (key === \"End\") {\n        e.preventDefault();\n        for (let i = optionStates.length - 1; i >= 0; i--) {\n          if (!optionStates[i].isDisabled) {\n            focusOptionAt(i);\n            return;\n          }\n        }\n        return;\n      }\n\n      if (key === \"Enter\" || key === \" \") {\n        e.preventDefault();\n        const current = optionStates[activeIndex];\n        if (!current || current.isDisabled) return;\n        onToggle?.(current.option.id);\n        return;\n      }\n    },\n    [\n      activeIndex,\n      findNextEnabledIndex,\n      focusOptionAt,\n      isExiting,\n      onToggle,\n      optionStates,\n    ],\n  );\n\n  const isTransitioning = transitionDirection !== undefined;\n\n  const enterClass =\n    transitionDirection === \"forward\"\n      ? \"motion-safe:slide-in-from-right-4\"\n      : \"motion-safe:slide-in-from-left-4\";\n\n  const exitClass =\n    transitionDirection === \"forward\"\n      ? \"motion-safe:slide-out-to-left-4\"\n      : \"motion-safe:slide-out-to-right-4\";\n\n  return (\n    <div\n      key={stepKey}\n      className={cn(\n        \"flex flex-col gap-4\",\n        isExiting && [\n          \"absolute inset-0\",\n          \"motion-safe:animate-out motion-safe:fade-out motion-safe:blur-out-sm motion-safe:duration-250 motion-safe:ease-[var(--cubic-ease-in-out)] motion-safe:fill-mode-forwards\",\n          exitClass,\n        ],\n        !isExiting &&\n          isTransitioning && [\n            \"motion-safe:animate-in motion-safe:fade-in motion-safe:blur-in-sm motion-safe:duration-250 motion-safe:ease-[var(--cubic-ease-in-out)] motion-safe:fill-mode-both\",\n            enterClass,\n          ],\n      )}\n      aria-hidden={isExiting}\n    >\n      <div className=\"flex flex-col gap-1\">\n        <h2 id={titleId} className=\"text-lg font-semibold leading-tight\">\n          {title}\n        </h2>\n        {description && (\n          <p id={descriptionId} className=\"text-muted-foreground text-sm\">\n            {description}\n          </p>\n        )}\n      </div>\n\n      <div\n        className=\"flex flex-col px-1\"\n        role=\"listbox\"\n        aria-multiselectable={selectionMode === \"multi\"}\n        onKeyDown={isExiting ? undefined : handleKeyDown}\n      >\n        {optionStates.map(({ option, isSelected, isDisabled }, index) => (\n          <Fragment key={option.id}>\n            {index > 0 && (\n              <Separator\n                className=\"transition-opacity [@media(hover:hover)]:[&:has(+_:hover)]:opacity-0 [@media(hover:hover)]:[.peer:hover+&]:opacity-0\"\n                orientation=\"horizontal\"\n              />\n            )}\n            <OptionItem\n              option={option}\n              isSelected={isSelected}\n              isDisabled={isExiting || isDisabled}\n              selectionMode={selectionMode}\n              isFirst={index === 0}\n              isLast={index === optionStates.length - 1}\n              tabIndex={isExiting ? -1 : index === activeIndex ? 0 : -1}\n              onFocus={() => !isExiting && setActiveIndex(index)}\n              buttonRef={(el) => {\n                optionRefs.current[index] = el;\n              }}\n              onToggle={() => !isExiting && onToggle?.(option.id)}\n            />\n          </Fragment>\n        ))}\n      </div>\n    </div>\n  );\n}\n\nfunction StepContent({\n  step,\n  totalSteps,\n  title,\n  description,\n  options,\n  selectionMode,\n  selectedIds,\n  onToggle,\n  onBack,\n  onNext,\n  showBack,\n  isLastStep,\n  id,\n  className,\n  stepKey,\n  exitingStepData,\n  transitionDirection = \"forward\",\n}: StepContentProps) {\n  const isTransitioning =\n    exitingStepData !== null && exitingStepData !== undefined;\n  const canProceed = selectedIds.size > 0;\n  const resolvedStepKey = stepKey ?? \"current\";\n  const { titleId, descriptionId } = getQuestionFlowStepIds(\n    id,\n    resolvedStepKey,\n  );\n\n  const stepLabel = totalSteps\n    ? `Step ${step} of ${totalSteps}`\n    : `Step ${step}`;\n\n  return (\n    <div\n      className={cn(\n        \"@container/question-flow flex w-full min-w-80 max-w-md flex-col gap-3\",\n        \"text-foreground\",\n        className,\n      )}\n      data-slot=\"question-flow\"\n      data-tool-ui-id={id}\n      role=\"form\"\n      aria-labelledby={titleId}\n      aria-describedby={description ? descriptionId : undefined}\n    >\n      <div\n        className={cn(\n          \"bg-card flex w-full flex-col gap-4 rounded-2xl border p-5 shadow-xs\",\n        )}\n      >\n        <div className=\"flex flex-col gap-1\">\n          <div className=\"flex flex-col gap-2\">\n            <span\n              className=\"text-muted-foreground text-xs font-medium uppercase tracking-wide\"\n              aria-label={stepLabel}\n            >\n              {stepLabel}\n            </span>\n            {totalSteps && <ProgressBar current={step} total={totalSteps} />}\n          </div>\n        </div>\n\n        <div className=\"relative mt-1\">\n          {exitingStepData && (\n            <StepBodyContent\n              key={exitingStepData.stepKey}\n              stepKey={exitingStepData.stepKey}\n              title={exitingStepData.title}\n              description={exitingStepData.description}\n              options={exitingStepData.options}\n              selectionMode={exitingStepData.selectionMode}\n              selectedIds={exitingStepData.selectedIds}\n              id={id}\n              isExiting\n              transitionDirection={transitionDirection}\n            />\n          )}\n          <StepBodyContent\n            key={resolvedStepKey}\n            stepKey={resolvedStepKey}\n            title={title}\n            description={description}\n            options={options}\n            selectionMode={selectionMode}\n            selectedIds={selectedIds}\n            onToggle={onToggle}\n            id={id}\n            isExiting={false}\n            transitionDirection={\n              exitingStepData ? transitionDirection : undefined\n            }\n          />\n        </div>\n\n        <div className=\"flex items-center justify-between pt-2\">\n          {showBack ? (\n            <Button\n              variant=\"ghost\"\n              size=\"default\"\n              onClick={onBack}\n              disabled={isTransitioning}\n              className=\"gap-1 rounded-full text-muted-foreground\"\n            >\n              <ChevronLeft className=\"size-4\" />\n              Back\n            </Button>\n          ) : (\n            <div />\n          )}\n          <Button\n            variant=\"default\"\n            size=\"default\"\n            onClick={onNext}\n            disabled={!canProceed || isTransitioning}\n            className=\"rounded-full\"\n          >\n            {isLastStep ? \"Complete\" : \"Next\"}\n          </Button>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction QuestionFlowProgressive({\n  id,\n  step,\n  title,\n  description,\n  options,\n  selectionMode = \"single\",\n  defaultValue,\n  onSelect,\n  onBack,\n  className,\n}: QuestionFlowProgressiveProps) {\n  const [selectedIds, setSelectedIds] = useState<Set<string>>(\n    () => new Set(defaultValue ?? []),\n  );\n\n  const handleToggle = useCallback(\n    (optionId: string) => {\n      setSelectedIds((prev) => {\n        const next = new Set(prev);\n        if (selectionMode === \"single\") {\n          if (next.has(optionId)) {\n            next.delete(optionId);\n          } else {\n            next.clear();\n            next.add(optionId);\n          }\n        } else {\n          if (next.has(optionId)) {\n            next.delete(optionId);\n          } else {\n            next.add(optionId);\n          }\n        }\n        return next;\n      });\n    },\n    [selectionMode],\n  );\n\n  const handleNext = useCallback(() => {\n    if (selectedIds.size === 0) return;\n    const selection = Array.from(selectedIds);\n    onSelect?.(selection);\n  }, [onSelect, selectedIds]);\n\n  return (\n    <StepContent\n      id={id}\n      step={step}\n      title={title}\n      description={description}\n      options={options}\n      selectionMode={selectionMode}\n      selectedIds={selectedIds}\n      onToggle={handleToggle}\n      onBack={onBack}\n      onNext={handleNext}\n      showBack={step > 1 && onBack !== undefined}\n      isLastStep={false}\n      className={className}\n    />\n  );\n}\n\nfunction QuestionFlowUpfront({\n  id,\n  steps,\n  onStepChange,\n  onComplete,\n  className,\n}: QuestionFlowUpfrontProps) {\n  const [currentStepIndex, setCurrentStepIndex] = useState(0);\n  const [answers, setAnswers] = useState<Record<string, string[]>>({});\n  const [exitingStepData, setExitingStepData] = useState<StepBodyData | null>(\n    null,\n  );\n  const [transitionDirection, setTransitionDirection] = useState<\n    \"forward\" | \"backward\"\n  >(\"forward\");\n\n  const currentStep = steps[currentStepIndex];\n  const isLastStep = currentStepIndex === steps.length - 1;\n  const totalSteps = steps.length;\n\n  useEffect(() => {\n    if (exitingStepData) {\n      const timer = setTimeout(() => setExitingStepData(null), 250);\n      return () => clearTimeout(timer);\n    }\n  }, [exitingStepData]);\n\n  const currentSelection = useMemo(() => {\n    const answer = answers[currentStep.id];\n    return new Set(answer ?? []);\n  }, [answers, currentStep.id]);\n\n  const handleToggle = useCallback(\n    (optionId: string) => {\n      const mode = currentStep.selectionMode ?? \"single\";\n      setAnswers((prev) => {\n        const current = prev[currentStep.id] ?? [];\n        let next: string[];\n\n        if (mode === \"single\") {\n          next = current.includes(optionId) ? [] : [optionId];\n        } else {\n          next = current.includes(optionId)\n            ? current.filter((id) => id !== optionId)\n            : [...current, optionId];\n        }\n\n        return { ...prev, [currentStep.id]: next };\n      });\n    },\n    [currentStep.id, currentStep.selectionMode],\n  );\n\n  const handleBack = useCallback(() => {\n    if (currentStepIndex > 0) {\n      const currentStepData = steps[currentStepIndex];\n      const stepOptions: QuestionFlowOption[] = currentStepData.options.map(\n        (opt) => ({\n          ...opt,\n          icon: undefined,\n        }),\n      );\n\n      setExitingStepData({\n        stepKey: currentStepData.id,\n        title: currentStepData.title,\n        description: currentStepData.description,\n        options: stepOptions,\n        selectionMode: currentStepData.selectionMode ?? \"single\",\n        selectedIds: new Set(answers[currentStepData.id] ?? []),\n      });\n      setTransitionDirection(\"backward\");\n      const prevIndex = currentStepIndex - 1;\n      setCurrentStepIndex(prevIndex);\n      onStepChange?.(steps[prevIndex].id);\n    }\n  }, [answers, currentStepIndex, onStepChange, steps]);\n\n  const handleNext = useCallback(() => {\n    if (currentSelection.size === 0) return;\n\n    if (isLastStep) {\n      onComplete?.(answers);\n    } else {\n      const currentStepData = steps[currentStepIndex];\n      const stepOptions: QuestionFlowOption[] = currentStepData.options.map(\n        (opt) => ({\n          ...opt,\n          icon: undefined,\n        }),\n      );\n\n      setExitingStepData({\n        stepKey: currentStepData.id,\n        title: currentStepData.title,\n        description: currentStepData.description,\n        options: stepOptions,\n        selectionMode: currentStepData.selectionMode ?? \"single\",\n        selectedIds: new Set(answers[currentStepData.id] ?? []),\n      });\n      setTransitionDirection(\"forward\");\n      const nextIndex = currentStepIndex + 1;\n      setCurrentStepIndex(nextIndex);\n      onStepChange?.(steps[nextIndex].id);\n    }\n  }, [\n    answers,\n    currentSelection.size,\n    currentStepIndex,\n    isLastStep,\n    onComplete,\n    onStepChange,\n    steps,\n  ]);\n\n  const stepOptions: QuestionFlowOption[] = currentStep.options.map((opt) => ({\n    ...opt,\n    icon: undefined,\n  }));\n\n  return (\n    <StepContent\n      id={id}\n      step={currentStepIndex + 1}\n      totalSteps={totalSteps}\n      title={currentStep.title}\n      description={currentStep.description}\n      options={stepOptions}\n      selectionMode={currentStep.selectionMode ?? \"single\"}\n      selectedIds={currentSelection}\n      onToggle={handleToggle}\n      onBack={handleBack}\n      onNext={handleNext}\n      showBack={currentStepIndex > 0}\n      isLastStep={isLastStep}\n      className={className}\n      stepKey={currentStep.id}\n      exitingStepData={exitingStepData}\n      transitionDirection={transitionDirection}\n    />\n  );\n}\n\nexport function QuestionFlow(props: QuestionFlowProps) {\n  if (\"choice\" in props && props.choice !== undefined) {\n    return <QuestionFlowReceipt {...(props as QuestionFlowReceiptProps)} />;\n  }\n\n  if (\"steps\" in props && props.steps !== undefined) {\n    return <QuestionFlowUpfront {...(props as QuestionFlowUpfrontProps)} />;\n  }\n\n  return (\n    <QuestionFlowProgressive {...(props as QuestionFlowProgressiveProps)} />\n  );\n}\n"
    },
    {
      "path": "components/tool-ui/question-flow/README.md",
      "type": "registry:file",
      "target": "components/tool-ui/question-flow/README.md",
      "content": "# Question Flow\n\nImplementation for the \"question-flow\" Tool UI surface.\n\n## Files\n\n- public exports: components/tool-ui/question-flow/index.tsx\n- serializable schema + parse helpers: components/tool-ui/question-flow/schema.ts\n\n## Companion assets\n\n- Docs page: app/docs/question-flow/content.mdx\n- Preset payload: lib/presets/question-flow.ts\n\n## Quick check\n\nRun this after edits:\n\npnpm test\n"
    },
    {
      "path": "components/tool-ui/question-flow/schema.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/question-flow/schema.ts",
      "content": "import { z } from \"zod\";\nimport type { ReactNode } from \"react\";\nimport { defineToolUiContract } from \"../shared/contract\";\nimport { ToolUIIdSchema, ToolUIRoleSchema } from \"../shared/schema\";\n\nexport const QuestionFlowOptionSchema = z.object({\n  id: z.string().min(1),\n  label: z.string().min(1),\n  description: z.string().optional(),\n  icon: z.custom<ReactNode>().optional(),\n  disabled: z.boolean().optional(),\n});\n\nexport type QuestionFlowOption = z.infer<typeof QuestionFlowOptionSchema>;\n\nexport const QuestionFlowStepDefinitionSchema = z.object({\n  id: z.string().min(1),\n  title: z.string().min(1),\n  description: z.string().optional(),\n  options: z.array(QuestionFlowOptionSchema.omit({ icon: true })).min(1),\n  selectionMode: z.enum([\"single\", \"multi\"]).optional(),\n});\n\nexport type QuestionFlowStepDefinition = z.infer<\n  typeof QuestionFlowStepDefinitionSchema\n>;\n\nexport const QuestionFlowSummaryItemSchema = z.object({\n  label: z.string().min(1),\n  value: z.string().min(1),\n});\n\nexport type QuestionFlowSummaryItem = z.infer<\n  typeof QuestionFlowSummaryItemSchema\n>;\n\nexport const QuestionFlowChoiceSchema = z.object({\n  title: z.string().min(1),\n  summary: z.array(QuestionFlowSummaryItemSchema).min(1),\n});\n\nexport type QuestionFlowChoice = z.infer<typeof QuestionFlowChoiceSchema>;\n\nconst BaseSchema = z.object({\n  id: ToolUIIdSchema,\n  role: ToolUIRoleSchema.optional(),\n});\n\nexport const SerializableProgressiveModeSchema = BaseSchema.extend({\n  step: z.number().min(1),\n  title: z.string().min(1),\n  description: z.string().optional(),\n  options: z.array(QuestionFlowOptionSchema.omit({ icon: true })).min(1),\n  selectionMode: z.enum([\"single\", \"multi\"]).optional(),\n});\n\nexport type SerializableProgressiveMode = z.infer<\n  typeof SerializableProgressiveModeSchema\n>;\n\nexport const SerializableUpfrontModeSchema = BaseSchema.extend({\n  steps: z.array(QuestionFlowStepDefinitionSchema).min(1),\n});\n\nexport type SerializableUpfrontMode = z.infer<\n  typeof SerializableUpfrontModeSchema\n>;\n\nexport const SerializableReceiptModeSchema = BaseSchema.extend({\n  choice: QuestionFlowChoiceSchema,\n});\n\nexport type SerializableReceiptMode = z.infer<\n  typeof SerializableReceiptModeSchema\n>;\n\nexport const SerializableQuestionFlowSchema = z.union([\n  SerializableProgressiveModeSchema,\n  SerializableUpfrontModeSchema,\n  SerializableReceiptModeSchema,\n]);\n\nexport type SerializableQuestionFlow = z.infer<\n  typeof SerializableQuestionFlowSchema\n>;\n\nconst SerializableQuestionFlowSchemaContract = defineToolUiContract(\n  \"QuestionFlow\",\n  SerializableQuestionFlowSchema,\n);\n\nexport const parseSerializableQuestionFlow: (\n  input: unknown,\n) => SerializableQuestionFlow = SerializableQuestionFlowSchemaContract.parse;\n\nexport const safeParseSerializableQuestionFlow: (\n  input: unknown,\n) => SerializableQuestionFlow | null =\n  SerializableQuestionFlowSchemaContract.safeParse;\ninterface BaseRuntimeProps {\n  className?: string;\n}\n\nexport interface QuestionFlowProgressiveProps\n  extends BaseRuntimeProps, Omit<SerializableProgressiveMode, \"options\"> {\n  options: QuestionFlowOption[];\n  defaultValue?: string[];\n  onSelect?: (optionIds: string[]) => void | Promise<void>;\n  onBack?: () => void;\n  steps?: never;\n  choice?: never;\n}\n\nexport interface QuestionFlowUpfrontProps\n  extends BaseRuntimeProps, SerializableUpfrontMode {\n  onStepChange?: (stepId: string) => void;\n  onComplete?: (answers: Record<string, string[]>) => void | Promise<void>;\n  step?: never;\n  choice?: never;\n}\n\nexport interface QuestionFlowReceiptProps\n  extends BaseRuntimeProps, SerializableReceiptMode {\n  step?: never;\n  steps?: never;\n}\n\nexport type QuestionFlowProps =\n  | QuestionFlowProgressiveProps\n  | QuestionFlowUpfrontProps\n  | QuestionFlowReceiptProps;\n"
    },
    {
      "path": "components/tool-ui/shared/contract.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/shared/contract.ts",
      "content": "import { z } from \"zod\";\nimport { parseWithSchema, safeParseWithSchema } from \"./parse\";\n\nexport interface ToolUiContract<T> {\n  schema: z.ZodType<T>;\n  parse: (input: unknown) => T;\n  safeParse: (input: unknown) => T | null;\n}\n\nexport function defineToolUiContract<T>(\n  componentName: string,\n  schema: z.ZodType<T>,\n): ToolUiContract<T> {\n  return {\n    schema,\n    parse: (input: unknown) => parseWithSchema(schema, input, componentName),\n    safeParse: (input: unknown) => safeParseWithSchema(schema, input),\n  };\n}\n"
    },
    {
      "path": "components/tool-ui/shared/parse.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/shared/parse.ts",
      "content": "import { z } from \"zod\";\n\nfunction formatZodPath(path: Array<string | number | symbol>): string {\n  if (path.length === 0) return \"root\";\n  return path\n    .map((segment) =>\n      typeof segment === \"number\" ? `[${segment}]` : String(segment),\n    )\n    .join(\".\");\n}\n\n/**\n * Format Zod errors into a compact `path: message` string.\n */\nexport function formatZodError(error: z.ZodError): string {\n  const parts = error.issues.map((issue) => {\n    const path = formatZodPath(issue.path);\n    return `${path}: ${issue.message}`;\n  });\n\n  return Array.from(new Set(parts)).join(\"; \");\n}\n\n/**\n * Parse unknown input and throw a readable error.\n */\nexport function parseWithSchema<T>(\n  schema: z.ZodType<T>,\n  input: unknown,\n  name: string,\n): T {\n  const res = schema.safeParse(input);\n  if (!res.success) {\n    throw new Error(`Invalid ${name} payload: ${formatZodError(res.error)}`);\n  }\n  return res.data;\n}\n\n/**\n * Parse unknown input, returning `null` instead of throwing on failure.\n *\n * Use this in assistant-ui `render` functions where `args` stream in\n * incrementally and may be incomplete until the tool call finishes.\n */\nexport function safeParseWithSchema<T>(\n  schema: z.ZodType<T>,\n  input: unknown,\n): T | null {\n  const res = schema.safeParse(input);\n  return res.success ? res.data : null;\n}\n"
    },
    {
      "path": "components/tool-ui/shared/schema.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/shared/schema.ts",
      "content": "import { z } from \"zod\";\nimport type { ReactNode } from \"react\";\n\n/**\n * Tool UI conventions:\n * - Serializable schemas are JSON-safe (no callbacks/ReactNode/`className`).\n * - Schema: `SerializableXSchema`\n * - Parser: `parseSerializableX(input: unknown)` (throws on invalid)\n * - Safe parser: `safeParseSerializableX(input: unknown)` (returns `null` on invalid)\n * - Actions: `LocalActions` for non-receipt actions and `DecisionActions` for consequential actions\n * - Root attrs: `data-tool-ui-id` + `data-slot`\n */\n\n/**\n * Schema for tool UI identity.\n *\n * Every tool UI should have a unique identifier that:\n * - Is stable across re-renders\n * - Is meaningful (not auto-generated)\n * - Is unique within the conversation\n *\n * Format recommendation: `{component-type}-{semantic-identifier}`\n * Examples: \"data-table-expenses-q3\", \"option-list-deploy-target\"\n */\nexport const ToolUIIdSchema = z.string().min(1);\n\nexport type ToolUIId = z.infer<typeof ToolUIIdSchema>;\n\n/**\n * Primary role of a Tool UI surface in a chat context.\n */\nexport const ToolUIRoleSchema = z.enum([\n  \"information\",\n  \"decision\",\n  \"control\",\n  \"state\",\n  \"composite\",\n]);\n\nexport type ToolUIRole = z.infer<typeof ToolUIRoleSchema>;\n\nexport const ToolUIReceiptOutcomeSchema = z.enum([\n  \"success\",\n  \"partial\",\n  \"failed\",\n  \"cancelled\",\n]);\n\nexport type ToolUIReceiptOutcome = z.infer<typeof ToolUIReceiptOutcomeSchema>;\n\n/**\n * Optional receipt metadata: a durable summary of an outcome.\n */\nexport const ToolUIReceiptSchema = z.object({\n  outcome: ToolUIReceiptOutcomeSchema,\n  summary: z.string().min(1),\n  identifiers: z.record(z.string(), z.string()).optional(),\n  at: z.string().datetime(),\n});\n\nexport type ToolUIReceipt = z.infer<typeof ToolUIReceiptSchema>;\n\n/**\n * Base schema for Tool UI payloads (id + optional role/receipt).\n */\nexport const ToolUISurfaceSchema = z.object({\n  id: ToolUIIdSchema,\n  role: ToolUIRoleSchema.optional(),\n  receipt: ToolUIReceiptSchema.optional(),\n});\n\nexport type ToolUISurface = z.infer<typeof ToolUISurfaceSchema>;\n\nexport const ActionSchema = z.object({\n  id: z.string().min(1),\n  label: z.string().min(1),\n  /**\n   * Canonical narration the assistant can use after this action is taken.\n   *\n   * Example: \"I exported the table as CSV.\" / \"I opened the link in a new tab.\"\n   */\n  sentence: z.string().optional(),\n  confirmLabel: z.string().optional(),\n  variant: z\n    .enum([\"default\", \"destructive\", \"secondary\", \"ghost\", \"outline\"])\n    .optional(),\n  icon: z.custom<ReactNode>().optional(),\n  loading: z.boolean().optional(),\n  disabled: z.boolean().optional(),\n  shortcut: z.string().optional(),\n});\n\nexport type Action = z.infer<typeof ActionSchema>;\nexport type LocalAction = Action;\nexport type DecisionAction = Action;\n\nexport const DecisionResultSchema = z.object({\n  kind: z.literal(\"decision\"),\n  version: z.literal(1),\n  decisionId: z.string().min(1),\n  actionId: z.string().min(1),\n  actionLabel: z.string().min(1),\n  at: z.string().datetime(),\n  payload: z.record(z.string(), z.unknown()).optional(),\n});\n\nexport type DecisionResult<\n  TPayload extends Record<string, unknown> = Record<string, unknown>,\n> = Omit<z.infer<typeof DecisionResultSchema>, \"payload\"> & {\n  payload?: TPayload;\n};\n\nexport function createDecisionResult<\n  TPayload extends Record<string, unknown> = Record<string, unknown>,\n>(args: {\n  decisionId: string;\n  action: { id: string; label: string };\n  payload?: TPayload;\n}): DecisionResult<TPayload> {\n  return {\n    kind: \"decision\",\n    version: 1,\n    decisionId: args.decisionId,\n    actionId: args.action.id,\n    actionLabel: args.action.label,\n    at: new Date().toISOString(),\n    payload: args.payload,\n  };\n}\n\nexport const ActionButtonsPropsSchema = z.object({\n  actions: z.array(ActionSchema).min(1),\n  align: z.enum([\"left\", \"center\", \"right\"]).optional(),\n  confirmTimeout: z.number().positive().optional(),\n  className: z.string().optional(),\n});\n\nexport const SerializableActionSchema = ActionSchema.omit({ icon: true });\nexport const SerializableActionsSchema = ActionButtonsPropsSchema.extend({\n  actions: z.array(SerializableActionSchema),\n}).omit({ className: true });\n\nexport interface ActionsConfig {\n  items: Action[];\n  align?: \"left\" | \"center\" | \"right\";\n  confirmTimeout?: number;\n}\n\nexport const SerializableActionsConfigSchema = z.object({\n  items: z.array(SerializableActionSchema).min(1),\n  align: z.enum([\"left\", \"center\", \"right\"]).optional(),\n  confirmTimeout: z.number().positive().optional(),\n});\n\nexport type SerializableActionsConfig = z.infer<\n  typeof SerializableActionsConfigSchema\n>;\n\nexport type SerializableAction = z.infer<typeof SerializableActionSchema>;\n"
    }
  ]
}
