{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "learning-core",
  "title": "Learning core",
  "description": "Shared card shell and xAPI telemetry. Every learning component depends on this; installing a component pulls it in automatically.",
  "files": [
    {
      "path": "components/learning/LearningCard.tsx",
      "content": "import type { ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface LearningCardProps {\n  /** Eyebrow label, e.g. \"Concept check\". Rendered uppercase. */\n  label: string;\n  /** Component type, exposed for the parser and the QA harness to find. */\n  type: string;\n  children: ReactNode;\n  className?: string;\n}\n\n/**\n * Shared shell for every learning component: surface, eyebrow, sizing. Keeping\n * the chrome here means a new component only writes its own interaction, and a\n * change to the surface applies to all of them at once.\n */\nexport function LearningCard({ label, type, children, className }: LearningCardProps) {\n  return (\n    <div\n      data-learning-type={type}\n      className={cn(\n        \"bg-card text-card-foreground w-full max-w-md rounded-xl border p-4 shadow-sm\",\n        className\n      )}\n    >\n      <div className=\"bg-primary text-primary-foreground mb-3 w-max rounded px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider\">\n        {label}\n      </div>\n      {children}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/learning/LearningCard.tsx"
    },
    {
      "path": "components/learning/ComponentBoundary.tsx",
      "content": "\"use client\";\nimport { Component, type ReactNode } from \"react\";\n\ninterface Props {\n  children: ReactNode;\n  fallback: ReactNode;\n}\n\n/**\n * Isolates one learning component's render from the rest of the page.\n *\n * Validation in the registry rejects malformed payloads, but it cannot prove a\n * component will not throw — a deep field can still be the wrong shape, and a\n * host app may pass its own props. Without a boundary, one bad activity in a\n * streamed lesson unmounts the entire host tree. The cost of being wrong here\n * is somebody else's app going blank, so the boundary is not optional.\n *\n * Class component because React exposes error catching only via lifecycle.\n */\nexport class ComponentBoundary extends Component<Props, { failed: boolean }> {\n  state = { failed: false };\n\n  static getDerivedStateFromError() {\n    return { failed: true };\n  }\n\n  componentDidCatch(error: unknown) {\n    // The host app owns real reporting; surfacing it beats swallowing it.\n    console.error(\"Learning component failed to render\", error);\n  }\n\n  render() {\n    return this.state.failed ? this.props.fallback : this.props.children;\n  }\n}\n",
      "type": "registry:component",
      "target": "@components/learning/ComponentBoundary.tsx"
    },
    {
      "path": "components/learning/xapi.ts",
      "content": "import { useCallback, useRef } from \"react\";\n\n/** Verbs we emit. Add here as components need them. */\nconst VERB_IDS = {\n  answered: \"http://adlnet.gov/expapi/verbs/answered\",\n  experienced: \"http://adlnet.gov/expapi/verbs/experienced\",\n  // Not an ADL verb — self-assessment has no ADL equivalent, and the registered\n  // tincanapi verb is what an LRS will already recognise for it.\n  rated: \"http://id.tincanapi.com/verb/rated\",\n} as const;\n\nexport type XApiVerb = keyof typeof VERB_IDS;\n\nexport interface XApiStatement {\n  verb: { id: string; display: { \"en-US\": string } };\n  object: { id: string; definition: { name: { \"en-US\": string } } };\n  result: {\n    response?: string;\n    success?: boolean;\n    completion: boolean;\n    duration: string;\n  };\n}\n\nexport interface StatementInput {\n  verb: XApiVerb;\n  activityId: string;\n  /** Human-readable activity name, e.g. the quiz prompt or flashcard front. */\n  name: string;\n  durationMs: number;\n  response?: string;\n  success?: boolean;\n}\n\nexport function buildStatement({\n  verb,\n  activityId,\n  name,\n  durationMs,\n  response,\n  success,\n}: StatementInput): XApiStatement {\n  const seconds = Math.max(0, Math.round(durationMs / 1000));\n  return {\n    verb: { id: VERB_IDS[verb], display: { \"en-US\": verb } },\n    object: {\n      id: `http://beadkit.dev/activities/${activityId}`,\n      definition: { name: { \"en-US\": name } },\n    },\n    result: {\n      // Omitted rather than sent as null: an LRS treats a present-but-null\n      // `success` as a scored attempt, which a flashcard view is not.\n      ...(response !== undefined && { response }),\n      ...(success !== undefined && { success }),\n      completion: true,\n      duration: `PT${seconds}S`,\n    },\n  };\n}\n\n/**\n * Times the learner's engagement and emits an xAPI statement. Every learning\n * component needs the same start-clock + emit pair, so it lives here rather\n * than being re-implemented per component.\n */\nexport function useTelemetry(\n  activityId: string,\n  name: string,\n  onTrackTelemetry?: (statement: XApiStatement) => void\n) {\n  // Set on first render, not on mount, so time spent before hydration counts.\n  const startedAt = useRef(Date.now());\n  return useCallback(\n    (verb: XApiVerb, extra: Pick<StatementInput, \"response\" | \"success\"> = {}) => {\n      onTrackTelemetry?.(\n        buildStatement({\n          verb,\n          activityId,\n          name,\n          durationMs: Date.now() - startedAt.current,\n          ...extra,\n        })\n      );\n    },\n    [activityId, name, onTrackTelemetry]\n  );\n}\n\n// Self-check: npx tsx components/learning/xapi.ts\nif (process.argv[1]?.endsWith(\"xapi.ts\")) {\n  const quiz = buildStatement({\n    verb: \"answered\",\n    activityId: \"quiz-1\",\n    name: \"HTTP Idempotency Check\",\n    durationMs: 14400,\n    response: \"B\",\n    success: true,\n  });\n  console.assert(quiz.object.id === \"http://beadkit.dev/activities/quiz-1\", \"object id\");\n  console.assert(quiz.verb.id.endsWith(\"/answered\"), \"verb id\");\n  console.assert(quiz.result.duration === \"PT14S\", \"duration rounds to seconds\");\n  console.assert(quiz.result.success === true && quiz.result.response === \"B\", \"result fields\");\n\n  const card = buildStatement({\n    verb: \"experienced\",\n    activityId: \"card-1\",\n    name: \"Idempotent\",\n    durationMs: 400,\n  });\n  console.assert(!(\"success\" in card.result), \"success omitted when not scored\");\n  console.assert(!(\"response\" in card.result), \"response omitted when not scored\");\n  console.assert(card.result.duration === \"PT0S\", \"sub-second rounds to PT0S\");\n  console.assert(card.verb.id.endsWith(\"/experienced\"), \"experienced verb id\");\n\n  // Self-assessment carries a response but no success: the learner's own rating\n  // is not a scored attempt, and an LRS reads `success` as one.\n  const rating = buildStatement({\n    verb: \"rated\",\n    activityId: \"mastery-1\",\n    name: \"REST fundamentals\",\n    durationMs: 9000,\n    response: \"solid\",\n  });\n  console.assert(rating.verb.id === \"http://id.tincanapi.com/verb/rated\", \"rated verb id\");\n  console.assert(rating.result.response === \"solid\", \"rating response\");\n  console.assert(!(\"success\" in rating.result), \"self-rating is not scored\");\n\n  console.log(\"xapi self-check passed\");\n}\n",
      "type": "registry:lib",
      "target": "@components/learning/xapi.ts"
    }
  ],
  "cssVars": {
    "light": {
      "success": "oklch(0.44 0.13 150.5)"
    },
    "dark": {
      "success": "oklch(0.79 0.19 150.5)"
    }
  },
  "type": "registry:component"
}