{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "knowledge-check",
  "type": "registry:component",
  "title": "Knowledge Check",
  "description": "Inline knowledge check with multiple-choice / true-false / fill-blank questions, per-answer feedback, and a final score summary.",
  "dependencies": [
    "@vllnt/ui@^0.2.1",
    "lucide-react"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/default/knowledge-check/knowledge-check.tsx",
      "content": "\"use client\";\n\nimport {\n  type ChangeEvent,\n  type ComponentPropsWithoutRef,\n  forwardRef,\n  type ReactNode,\n  useCallback,\n  useId,\n  useMemo,\n  useState,\n} from \"react\";\n\nimport { CheckCircle2, RotateCcw, XCircle } from \"lucide-react\";\n\nimport { cn } from \"@vllnt/ui\";\nimport { Button } from \"@vllnt/ui\";\nimport { Input } from \"@vllnt/ui\";\n\n/**\n * Question kind for {@link KnowledgeCheckQuestion}.\n *\n * @public\n */\nexport type KnowledgeCheckQuestionType =\n  | \"fill-blank\"\n  | \"multiple-choice\"\n  | \"true-false\";\n\n/**\n * Localizable strings.\n *\n * @public\n */\nexport type KnowledgeCheckLabels = {\n  /** Caption for the previous-question button. Defaults to `\"Back\"`. */\n  back?: string;\n  /** Caption for the check-answer button. Defaults to `\"Check\"`. */\n  check?: string;\n  /** Caption for the correct-answer feedback. Defaults to `\"Correct\"`. */\n  correct?: string;\n  /** Caption for the false option. Defaults to `\"False\"`. */\n  falseOption?: string;\n  /** Caption for the incorrect-answer feedback. Defaults to `\"Try again\"`. */\n  incorrect?: string;\n  /** Caption for the next-question button. Defaults to `\"Next\"`. */\n  next?: string;\n  /** Caption for the summary \"out of\" connector. Defaults to `\"of\"`. */\n  outOf?: string;\n  /** Caption for the retry button. Defaults to `\"Retry\"`. */\n  retry?: string;\n  /** Heading above the score summary. Defaults to `\"You scored\"`. */\n  scored?: string;\n  /** Caption for the true option. Defaults to `\"True\"`. */\n  trueOption?: string;\n};\n\nconst DEFAULT_LABELS = {\n  back: \"Back\",\n  check: \"Check\",\n  correct: \"Correct\",\n  falseOption: \"False\",\n  incorrect: \"Try again\",\n  next: \"Next\",\n  outOf: \"of\",\n  retry: \"Retry\",\n  scored: \"You scored\",\n  trueOption: \"True\",\n} as const satisfies Required<KnowledgeCheckLabels>;\n\n/**\n * Choice option entry passed to a choice question.\n *\n * @public\n */\nexport type KnowledgeCheckOption = {\n  /** When true, this option is the correct answer. */\n  correct?: boolean;\n  /** Display label. */\n  label: ReactNode;\n  /** Stable identifier within the question. */\n  value: string;\n};\n\ntype FillBlankQuestion = {\n  /** Expected answer string. */\n  answer: string;\n  /** When true, comparison is case-sensitive. Defaults to `false`. */\n  caseSensitive?: boolean;\n  /** Optional explanation shown after the user submits. */\n  explanation?: ReactNode;\n  /** Stable identifier. */\n  id: string;\n  /** Question prompt. */\n  question: ReactNode;\n  type: \"fill-blank\";\n};\n\ntype MultipleChoiceQuestion = {\n  /** Optional explanation shown after the user submits. */\n  explanation?: ReactNode;\n  /** Stable identifier. */\n  id: string;\n  /** Option list. Mark the correct option(s) with `correct: true`. */\n  options: KnowledgeCheckOption[];\n  /** Question prompt. */\n  question: ReactNode;\n  type: \"multiple-choice\";\n};\n\ntype TrueFalseQuestion = {\n  /** Expected boolean answer. */\n  answer: boolean;\n  /** Optional explanation shown after the user submits. */\n  explanation?: ReactNode;\n  /** Stable identifier. */\n  id: string;\n  /** Question prompt. */\n  question: ReactNode;\n  type: \"true-false\";\n};\n\n/**\n * Question entry passed to {@link KnowledgeCheckProps.questions}.\n *\n * @public\n */\nexport type KnowledgeCheckQuestion =\n  | FillBlankQuestion\n  | MultipleChoiceQuestion\n  | TrueFalseQuestion;\n\n/**\n * Per-question result reported to the consumer.\n *\n * @public\n */\nexport type KnowledgeCheckAnswer = {\n  /** True when the user's response matched the expected answer. */\n  correct: boolean;\n  /** Question id. */\n  questionId: string;\n  /** The user's raw response (string for `fill-blank`, option value for choice, boolean for `true-false`). */\n  response: boolean | string;\n};\n\n/**\n * Score payload reported on completion.\n *\n * @public\n */\nexport type KnowledgeCheckScore = {\n  /** Map of question id → answer. */\n  answers: Record<string, KnowledgeCheckAnswer>;\n  /** Right-answer count. */\n  correct: number;\n  /** Total number of questions. */\n  total: number;\n};\n\nfunction compareFillBlank(\n  question: FillBlankQuestion,\n  response: string,\n): boolean {\n  const normalize = (value: string): string =>\n    question.caseSensitive ? value.trim() : value.trim().toLowerCase();\n  return normalize(response) === normalize(question.answer);\n}\n\nfunction isMultipleChoiceCorrect(\n  question: MultipleChoiceQuestion,\n  value: string,\n): boolean {\n  return question.options.some(\n    (option) => option.value === value && option.correct === true,\n  );\n}\n\nfunction evaluateAnswer(\n  question: KnowledgeCheckQuestion,\n  response: boolean | string,\n): boolean {\n  switch (question.type) {\n    case \"fill-blank\":\n      return (\n        typeof response === \"string\" && compareFillBlank(question, response)\n      );\n    case \"multiple-choice\":\n      return (\n        typeof response === \"string\" &&\n        isMultipleChoiceCorrect(question, response)\n      );\n    case \"true-false\":\n      return typeof response === \"boolean\" && response === question.answer;\n  }\n}\n\ntype ResponseMap = Record<string, boolean | string>;\ntype AnswerMap = Record<string, KnowledgeCheckAnswer>;\n\ntype ControllerState = {\n  answers: AnswerMap;\n  current: KnowledgeCheckQuestion;\n  handleNext: () => void;\n  handlePrevious: () => void;\n  handleReset: () => void;\n  handleSubmit: () => void;\n  index: number;\n  isComplete: boolean;\n  isFirst: boolean;\n  isLast: boolean;\n  responses: ResponseMap;\n  score?: KnowledgeCheckScore;\n  setResponse: (questionId: string, value: boolean | string) => void;\n};\n\ntype ControllerOptions = {\n  onAnswer?: (answer: KnowledgeCheckAnswer) => void;\n  onComplete?: (score: KnowledgeCheckScore) => void;\n  questions: KnowledgeCheckQuestion[];\n};\n\nfunction useKnowledgeCheckController(\n  options: ControllerOptions,\n): ControllerState {\n  const { onAnswer, onComplete, questions } = options;\n  const [index, setIndex] = useState(0);\n  const [responses, setResponses] = useState<ResponseMap>({});\n  const [answers, setAnswers] = useState<AnswerMap>({});\n  const [completed, setCompleted] = useState(false);\n\n  const setResponse = useCallback(\n    (questionId: string, value: boolean | string) => {\n      setResponses((current) => ({ ...current, [questionId]: value }));\n    },\n    [],\n  );\n\n  const handleSubmit = useCallback(() => {\n    const question = questions[index];\n    if (!question) return;\n    const response = responses[question.id];\n    if (response === undefined) return;\n    const answer: KnowledgeCheckAnswer = {\n      correct: evaluateAnswer(question, response),\n      questionId: question.id,\n      response,\n    };\n    setAnswers((current) => ({ ...current, [question.id]: answer }));\n    onAnswer?.(answer);\n  }, [index, onAnswer, questions, responses]);\n\n  const handleNext = useCallback(() => {\n    if (index >= questions.length - 1) {\n      const finalScore = computeScore(questions, answers);\n      setCompleted(true);\n      onComplete?.(finalScore);\n      return;\n    }\n    setIndex((value) => value + 1);\n  }, [answers, index, onComplete, questions]);\n\n  const handlePrevious = useCallback(() => {\n    setIndex((value) => Math.max(0, value - 1));\n  }, []);\n\n  const handleReset = useCallback(() => {\n    setIndex(0);\n    setResponses({});\n    setAnswers({});\n    setCompleted(false);\n  }, []);\n\n  const score: KnowledgeCheckScore | undefined = useMemo(\n    () => (completed ? computeScore(questions, answers) : undefined),\n    [answers, completed, questions],\n  );\n\n  const current = questions[index] ?? questions[0];\n  if (!current) {\n    throw new Error(\"KnowledgeCheck requires at least one question\");\n  }\n  return {\n    answers,\n    current,\n    handleNext,\n    handlePrevious,\n    handleReset,\n    handleSubmit,\n    index,\n    isComplete: completed,\n    isFirst: index === 0,\n    isLast: index === questions.length - 1,\n    responses,\n    score,\n    setResponse,\n  };\n}\n\nfunction computeScore(\n  questions: KnowledgeCheckQuestion[],\n  answers: AnswerMap,\n): KnowledgeCheckScore {\n  const correct = Object.values(answers).filter(\n    (entry) => entry.correct,\n  ).length;\n  return { answers, correct, total: questions.length };\n}\n\n/**\n * Props for {@link KnowledgeCheck}.\n *\n * @public\n */\nexport type KnowledgeCheckProps = {\n  /** Localizable strings. */\n  labels?: KnowledgeCheckLabels;\n  /** Fires after the user submits an answer. */\n  onAnswer?: (answer: KnowledgeCheckAnswer) => void;\n  /** Fires when the user finishes the last question. */\n  onComplete?: (score: KnowledgeCheckScore) => void;\n  /** Question list. Must contain at least one entry. */\n  questions: KnowledgeCheckQuestion[];\n  /** Optional title shown above the questions. */\n  title?: ReactNode;\n} & ComponentPropsWithoutRef<\"section\">;\n\ntype FeedbackProps = {\n  answer?: KnowledgeCheckAnswer;\n  correctLabel: string;\n  explanation?: ReactNode;\n  incorrectLabel: string;\n};\n\nfunction Feedback({\n  answer,\n  correctLabel,\n  explanation,\n  incorrectLabel,\n}: FeedbackProps): ReactNode {\n  if (!answer) return null;\n  return (\n    <div\n      aria-live=\"polite\"\n      className={cn(\n        \"flex items-start gap-2 rounded-md border p-3 text-sm\",\n        answer.correct\n          ? \"border-emerald-500/40 bg-emerald-500/10 text-emerald-900 dark:text-emerald-200\"\n          : \"border-destructive/40 bg-destructive/10 text-destructive\",\n      )}\n      role=\"status\"\n    >\n      {answer.correct ? (\n        <CheckCircle2 aria-hidden=\"true\" className=\"mt-0.5 size-4 shrink-0\" />\n      ) : (\n        <XCircle aria-hidden=\"true\" className=\"mt-0.5 size-4 shrink-0\" />\n      )}\n      <div className=\"flex flex-col gap-1\">\n        <span className=\"font-medium\">\n          {answer.correct ? correctLabel : incorrectLabel}\n        </span>\n        {explanation ? (\n          <span className=\"text-xs opacity-80\">{explanation}</span>\n        ) : null}\n      </div>\n    </div>\n  );\n}\n\ntype MultipleChoiceFieldProps = {\n  groupName: string;\n  onChange: (value: string) => void;\n  options: KnowledgeCheckOption[];\n  value?: string;\n};\n\ntype MultipleChoiceOptionProps = {\n  groupName: string;\n  onChange: (event: ChangeEvent<HTMLInputElement>) => void;\n  option: KnowledgeCheckOption;\n  value?: string;\n};\n\nfunction MultipleChoiceOption({\n  groupName,\n  onChange,\n  option,\n  value,\n}: MultipleChoiceOptionProps): ReactNode {\n  const id = `${groupName}-${option.value}`;\n  return (\n    <div className=\"flex items-center gap-2 rounded-md border border-border bg-background px-3 py-2 text-sm hover:bg-accent\">\n      <input\n        checked={value === option.value}\n        className=\"size-4\"\n        id={id}\n        name={groupName}\n        onChange={onChange}\n        type=\"radio\"\n        value={option.value}\n      />\n      <label className=\"flex-1 cursor-pointer\" htmlFor={id}>\n        {option.label}\n      </label>\n    </div>\n  );\n}\n\nfunction MultipleChoiceField({\n  groupName,\n  onChange,\n  options,\n  value,\n}: MultipleChoiceFieldProps): ReactNode {\n  const handleSelect = useCallback(\n    (event: ChangeEvent<HTMLInputElement>) => {\n      onChange(event.target.value);\n    },\n    [onChange],\n  );\n  return (\n    <div className=\"flex flex-col gap-1.5\" role=\"radiogroup\">\n      {options.map((option) => (\n        <MultipleChoiceOption\n          groupName={groupName}\n          key={option.value}\n          onChange={handleSelect}\n          option={option}\n          value={value}\n        />\n      ))}\n    </div>\n  );\n}\n\ntype TrueFalseFieldProps = {\n  falseLabel: string;\n  groupName: string;\n  onChange: (value: boolean) => void;\n  trueLabel: string;\n  value?: boolean;\n};\n\nfunction TrueFalseField({\n  falseLabel,\n  groupName,\n  onChange,\n  trueLabel,\n  value,\n}: TrueFalseFieldProps): ReactNode {\n  const handleSelectTrue = useCallback(() => {\n    onChange(true);\n  }, [onChange]);\n  const handleSelectFalse = useCallback(() => {\n    onChange(false);\n  }, [onChange]);\n  return (\n    <div\n      aria-label={groupName}\n      className=\"flex flex-wrap gap-2\"\n      role=\"radiogroup\"\n    >\n      <Button\n        aria-pressed={value === true}\n        onClick={handleSelectTrue}\n        size=\"sm\"\n        type=\"button\"\n        variant={value === true ? \"default\" : \"outline\"}\n      >\n        {trueLabel}\n      </Button>\n      <Button\n        aria-pressed={value === false}\n        onClick={handleSelectFalse}\n        size=\"sm\"\n        type=\"button\"\n        variant={value === false ? \"default\" : \"outline\"}\n      >\n        {falseLabel}\n      </Button>\n    </div>\n  );\n}\n\ntype FillBlankFieldProps = {\n  inputId: string;\n  onChange: (value: string) => void;\n  value: string;\n};\n\nfunction FillBlankField({\n  inputId,\n  onChange,\n  value,\n}: FillBlankFieldProps): ReactNode {\n  const handleChange = useCallback(\n    (event: ChangeEvent<HTMLInputElement>) => {\n      onChange(event.target.value);\n    },\n    [onChange],\n  );\n  return <Input id={inputId} onChange={handleChange} value={value} />;\n}\n\ntype QuestionFieldProps = {\n  groupName: string;\n  inputId: string;\n  labels: Required<KnowledgeCheckLabels>;\n  onResponse: (value: boolean | string) => void;\n  question: KnowledgeCheckQuestion;\n  response?: boolean | string;\n};\n\nfunction QuestionField({\n  groupName,\n  inputId,\n  labels,\n  onResponse,\n  question,\n  response,\n}: QuestionFieldProps): ReactNode {\n  switch (question.type) {\n    case \"fill-blank\":\n      return (\n        <FillBlankField\n          inputId={inputId}\n          onChange={onResponse}\n          value={typeof response === \"string\" ? response : \"\"}\n        />\n      );\n    case \"multiple-choice\":\n      return (\n        <MultipleChoiceField\n          groupName={groupName}\n          onChange={onResponse}\n          options={question.options}\n          value={typeof response === \"string\" ? response : undefined}\n        />\n      );\n    case \"true-false\":\n      return (\n        <TrueFalseField\n          falseLabel={labels.falseOption}\n          groupName={groupName}\n          onChange={onResponse}\n          trueLabel={labels.trueOption}\n          value={typeof response === \"boolean\" ? response : undefined}\n        />\n      );\n  }\n}\n\ntype ScoreSummaryProps = {\n  labels: Required<KnowledgeCheckLabels>;\n  onRetry: () => void;\n  score: KnowledgeCheckScore;\n};\n\nfunction ScoreSummary({\n  labels,\n  onRetry,\n  score,\n}: ScoreSummaryProps): ReactNode {\n  return (\n    <div className=\"flex flex-col items-center gap-3 rounded-lg border border-border bg-muted/20 p-6 text-center\">\n      <p className=\"text-sm text-muted-foreground\">{labels.scored}</p>\n      <p className=\"text-3xl font-bold tracking-tight text-foreground\">\n        {score.correct} {labels.outOf} {score.total}\n      </p>\n      <Button onClick={onRetry} size=\"sm\" type=\"button\" variant=\"outline\">\n        <RotateCcw aria-hidden=\"true\" className=\"mr-2 size-4\" />\n        {labels.retry}\n      </Button>\n    </div>\n  );\n}\n\ntype QuestionPaneProps = {\n  controller: ControllerState;\n  groupName: string;\n  inputId: string;\n  labels: Required<KnowledgeCheckLabels>;\n  questions: KnowledgeCheckQuestion[];\n};\n\nfunction QuestionPane({\n  controller,\n  groupName,\n  inputId,\n  labels,\n  questions,\n}: QuestionPaneProps): ReactNode {\n  const question = controller.current;\n  const response = controller.responses[question.id];\n  const answer = controller.answers[question.id];\n  const handleResponse = useCallback(\n    (value: boolean | string) => {\n      controller.setResponse(question.id, value);\n    },\n    [controller, question.id],\n  );\n  return (\n    <div className=\"flex flex-col gap-3\">\n      <p className=\"text-xs font-medium uppercase tracking-wide text-muted-foreground\">\n        {`${(controller.index + 1).toString()} ${labels.outOf} ${questions.length.toString()}`}\n      </p>\n      <p className=\"text-sm font-medium text-foreground\">{question.question}</p>\n      <QuestionField\n        groupName={groupName}\n        inputId={inputId}\n        labels={labels}\n        onResponse={handleResponse}\n        question={question}\n        response={response}\n      />\n      <Feedback\n        answer={answer}\n        correctLabel={labels.correct}\n        explanation={question.explanation}\n        incorrectLabel={labels.incorrect}\n      />\n      <PaneActions\n        answered={answer !== undefined}\n        controller={controller}\n        labels={labels}\n        responseSet={response !== undefined}\n      />\n    </div>\n  );\n}\n\ntype PaneActionsProps = {\n  answered: boolean;\n  controller: ControllerState;\n  labels: Required<KnowledgeCheckLabels>;\n  responseSet: boolean;\n};\n\nfunction PaneActions({\n  answered,\n  controller,\n  labels,\n  responseSet,\n}: PaneActionsProps): ReactNode {\n  return (\n    <div className=\"flex items-center justify-between gap-2 pt-1\">\n      <Button\n        disabled={controller.isFirst}\n        onClick={controller.handlePrevious}\n        size=\"sm\"\n        type=\"button\"\n        variant=\"ghost\"\n      >\n        {labels.back}\n      </Button>\n      <div className=\"flex items-center gap-2\">\n        {answered ? (\n          <Button onClick={controller.handleNext} size=\"sm\" type=\"button\">\n            {controller.isLast ? labels.scored : labels.next}\n          </Button>\n        ) : (\n          <Button\n            disabled={!responseSet}\n            onClick={controller.handleSubmit}\n            size=\"sm\"\n            type=\"button\"\n          >\n            {labels.check}\n          </Button>\n        )}\n      </div>\n    </div>\n  );\n}\n\n/**\n * Inline knowledge check for embedding within content. Supports three\n * question types (choice, true-false, fill-blank) with per-question\n * feedback (correct / incorrect + optional `explanation`) and a final\n * score summary with retry. Composes {@link Input} and {@link Button}.\n *\n * Question state lives inside the component. Drive analytics or\n * persistence from the `onAnswer` and `onComplete` callbacks.\n *\n * @example\n * ```tsx\n * <KnowledgeCheck\n *   title=\"Check your understanding\"\n *   questions={[\n *     {\n *       id: \"react-framework\",\n *       type: \"true-false\",\n *       question: \"React is a framework.\",\n *       answer: false,\n *     },\n *     {\n *       id: \"state-hook\",\n *       type: \"fill-blank\",\n *       question: \"The hook for state is ___\",\n *       answer: \"useState\",\n *     },\n *   ]}\n *   onComplete={(score) => track(\"kc:complete\", score)}\n * />\n * ```\n *\n * @public\n */\nexport const KnowledgeCheck = forwardRef<HTMLElement, KnowledgeCheckProps>(\n  (props, ref) => {\n    const {\n      className,\n      labels,\n      onAnswer,\n      onComplete,\n      questions,\n      title,\n      ...rest\n    } = props;\n    const resolvedLabels = useMemo(\n      () => ({ ...DEFAULT_LABELS, ...labels }),\n      [labels],\n    );\n    const groupName = useId();\n    const inputId = useId();\n    const controller = useKnowledgeCheckController({\n      onAnswer,\n      onComplete,\n      questions,\n    });\n\n    return (\n      <section\n        className={cn(\n          \"flex flex-col gap-4 rounded-2xl border bg-background p-4\",\n          className,\n        )}\n        ref={ref}\n        {...rest}\n      >\n        {title ? (\n          <h3 className=\"text-base font-semibold tracking-tight text-foreground\">\n            {title}\n          </h3>\n        ) : null}\n        {controller.isComplete && controller.score ? (\n          <ScoreSummary\n            labels={resolvedLabels}\n            onRetry={controller.handleReset}\n            score={controller.score}\n          />\n        ) : (\n          <QuestionPane\n            controller={controller}\n            groupName={groupName}\n            inputId={inputId}\n            labels={resolvedLabels}\n            questions={questions}\n          />\n        )}\n      </section>\n    );\n  },\n);\nKnowledgeCheck.displayName = \"KnowledgeCheck\";\n",
      "type": "registry:component"
    }
  ],
  "version": "0.2.1",
  "stability": "stable"
}
