{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "learning-stream",
  "title": "Learning stream",
  "description": "Parses <LEARNING_COMPONENT> payloads out of streaming model output and hydrates them inline. Pulls in every component it can render.",
  "registryDependencies": [
    "https://beadkit.dev/r/quiz.json",
    "https://beadkit.dev/r/flashcard.json",
    "https://beadkit.dev/r/sequence.json",
    "https://beadkit.dev/r/mastery.json"
  ],
  "files": [
    {
      "path": "components/learning/parse.ts",
      "content": "import { registry, type LearningType } from \"./registry\";\n\nconst OPEN = \"<LEARNING_COMPONENT>\";\nconst CLOSE = \"</LEARNING_COMPONENT>\";\n\nexport type Segment =\n  | { kind: \"text\"; text: string }\n  /** Validated payload, ready to render. */\n  | { kind: \"component\"; type: LearningType; data: Record<string, unknown>; activityId: string }\n  /** Tag closed but the payload was malformed or of an unknown type. */\n  | { kind: \"invalid\"; activityId: string }\n  /** Tag opened and the stream has not closed it yet. */\n  | { kind: \"pending\" };\n\n/**\n * If the text ends mid-tag (\"...<LEARNING_COMP\"), that fragment is the start of\n * a component, not prose. Without this the raw tag characters flash on screen\n * as the stream arrives.\n */\nfunction trimPartialOpenTag(text: string): string {\n  for (let i = OPEN.length - 1; i > 0; i--) {\n    if (text.endsWith(OPEN.slice(0, i))) return text.slice(0, -i);\n  }\n  return text;\n}\n\n/** The protocol forbids code fences inside the tags, but models add them anyway. */\nfunction stripFences(raw: string): string {\n  return raw\n    .replace(/^\\s*```(?:json)?\\s*/i, \"\")\n    .replace(/\\s*```\\s*$/, \"\")\n    .trim();\n}\n\nfunction classify(raw: string, activityId: string): Segment {\n  let data: unknown;\n  try {\n    data = JSON.parse(stripFences(raw));\n  } catch {\n    return { kind: \"invalid\", activityId };\n  }\n  if (typeof data !== \"object\" || data === null || Array.isArray(data)) {\n    return { kind: \"invalid\", activityId };\n  }\n  const record = data as Record<string, unknown>;\n  const type = record.type;\n  if (typeof type !== \"string\" || !(type in registry)) {\n    return { kind: \"invalid\", activityId };\n  }\n  const entry = registry[type as LearningType];\n  // Validate now so the stream never hands a broken payload to a component.\n  if (entry.parse(record, activityId) === null) {\n    return { kind: \"invalid\", activityId };\n  }\n  return { kind: \"component\", type: type as LearningType, data: record, activityId };\n}\n\n/**\n * Splits accumulated LLM output into prose and learning components.\n *\n * Takes the full text so far rather than a delta, so it is pure and idempotent:\n * calling it on every chunk cannot drift out of sync with the stream the way an\n * incremental state machine can. Text after an unclosed tag is withheld as\n * `pending` — the payload is still arriving and cannot be trusted yet.\n */\nexport function parseLearningStream(text: string): Segment[] {\n  const segments: Segment[] = [];\n  const pushText = (t: string) => {\n    if (t.trim()) segments.push({ kind: \"text\", text: t.trim() });\n  };\n\n  let rest = text;\n  let index = 0;\n  for (;;) {\n    const openAt = rest.indexOf(OPEN);\n    if (openAt === -1) {\n      pushText(trimPartialOpenTag(rest));\n      return segments;\n    }\n    pushText(rest.slice(0, openAt));\n\n    const after = rest.slice(openAt + OPEN.length);\n    const closeAt = after.indexOf(CLOSE);\n    if (closeAt === -1) {\n      // Still streaming. Anything after an unclosed tag is part of this payload.\n      segments.push({ kind: \"pending\" });\n      return segments;\n    }\n    // Ordinal, not a content hash: components are only ever appended, so an\n    // earlier component keeps its id as more of the stream arrives, which keeps\n    // xAPI statements pointing at a stable activity across re-renders.\n    segments.push(classify(after.slice(0, closeAt), `streamed-${index}`));\n    index++;\n    rest = after.slice(closeAt + CLOSE.length);\n  }\n}\n\n// Self-check: npx tsx components/learning/parse.ts\nif (process.argv[1]?.endsWith(\"parse.ts\")) {\n  const kinds = (t: string) => parseLearningStream(t).map((s) => s.kind).join(\",\");\n  const quiz = JSON.stringify({\n    type: \"quiz\",\n    prompt: \"Which is idempotent?\",\n    options: [\n      { id: \"A\", text: \"POST\", valid: false, feedback: \"no\" },\n      { id: \"B\", text: \"GET\", valid: true, feedback: \"yes\" },\n    ],\n  });\n  const wrap = (json: string) => `${OPEN}${json}${CLOSE}`;\n\n  console.assert(kinds(\"just prose\") === \"text\", \"plain text\");\n  console.assert(kinds(\"\") === \"\", \"empty stream yields nothing\");\n  console.assert(kinds(`Intro ${wrap(quiz)} outro`) === \"text,component,text\", \"text around\");\n  console.assert(kinds(`${wrap(quiz)}${wrap(quiz)}`) === \"component,component\", \"back to back\");\n\n  // Cut-off mid-payload: withheld, never rendered half-parsed.\n  console.assert(kinds(`Intro ${OPEN}{\"type\":\"qu`) === \"text,pending\", \"unclosed tag pends\");\n  console.assert(kinds(`Intro ${OPEN}`) === \"text,pending\", \"bare open tag pends\");\n\n  // A partially-arrived open tag must not leak to the reader as prose.\n  console.assert(kinds(\"Intro <LEARNING_COMP\") === \"text\", \"partial tag is not prose\");\n  console.assert(\n    parseLearningStream(\"Intro <LEARNING_COMP\")[0]?.kind === \"text\" &&\n      (parseLearningStream(\"Intro <LEARNING_COMP\")[0] as { text: string }).text === \"Intro\",\n    \"partial tag trimmed from text\"\n  );\n\n  // Malformed payloads degrade to `invalid`, never throw.\n  console.assert(kinds(wrap(\"{not json\")) === \"invalid\", \"corrupt json\");\n  console.assert(kinds(wrap(\"[1,2]\")) === \"invalid\", \"array payload\");\n  console.assert(kinds(wrap('{\"type\":\"unknown\"}')) === \"invalid\", \"unknown type\");\n  console.assert(kinds(wrap('{\"type\":\"quiz\"}')) === \"invalid\", \"quiz missing fields\");\n  console.assert(\n    kinds(wrap('{\"type\":\"quiz\",\"prompt\":\"p\",\"options\":[{\"id\":\"A\",\"text\":\"a\",\"valid\":false}]}')) ===\n      \"invalid\",\n    \"quiz with no correct answer\"\n  );\n  console.assert(\n    kinds(wrap('{\"type\":\"flashcard\",\"front\":\"f\",\"back\":\"\"}')) === \"invalid\",\n    \"flashcard with empty back\"\n  );\n  console.assert(\n    kinds(wrap('{\"type\":\"flashcard\",\"front\":\"f\",\"back\":\"b\"}')) === \"component\",\n    \"valid flashcard\"\n  );\n  console.assert(kinds(wrap(\"```json\\n\" + quiz + \"\\n```\")) === \"component\", \"code fences stripped\");\n\n  // The property that matters most: every prefix of a real stream is safe.\n  const full = `Lead in.\\n\\n${wrap(quiz)}\\n\\nMiddle.\\n\\n${wrap(\n    JSON.stringify({ type: \"flashcard\", front: \"f\", back: \"b\" })\n  )}\\n\\nTail.`;\n  for (let i = 0; i <= full.length; i++) {\n    const segs = parseLearningStream(full.slice(0, i));\n    console.assert(!segs.some((s) => s.kind === \"invalid\"), `prefix ${i} produced invalid`);\n    const text = segs\n      .filter((s): s is { kind: \"text\"; text: string } => s.kind === \"text\")\n      .map((s) => s.text)\n      .join(\" \");\n    console.assert(!text.includes(\"<LEARNING\"), `prefix ${i} leaked a tag into prose`);\n    console.assert(!text.includes('\"type\"'), `prefix ${i} leaked payload json into prose`);\n  }\n  console.assert(parseLearningStream(full).filter((s) => s.kind === \"component\").length === 2, \"final\");\n\n  // Every registry demo must survive its own validator. This is what keeps the\n  // documented schemas in PROMPT.md honest: the demos are the worked examples,\n  // so a validator tightened without updating them fails here.\n  for (const [type, e] of Object.entries(registry)) {\n    const payload = JSON.stringify({ type, ...e.demo });\n    console.assert(kinds(wrap(payload)) === \"component\", `registry demo \"${type}\" fails its parser`);\n  }\n\n  console.log(\"parse self-check passed\");\n}\n",
      "type": "registry:lib",
      "target": "@components/learning/parse.ts"
    },
    {
      "path": "components/learning/registry.tsx",
      "content": "import type { ComponentType, ReactNode } from \"react\";\nimport { FlashcardBlock } from \"./FlashcardBlock\";\nimport { MasteryMeter } from \"./MasteryMeter\";\nimport { QuizBlock } from \"./QuizBlock\";\nimport { SequenceBuilder } from \"./SequenceBuilder\";\nimport type { XApiStatement } from \"./xapi\";\n\ntype Track = (statement: XApiStatement) => void;\n\n/** Props every learning component accepts. */\nexport interface LearningComponentProps {\n  activityId: string;\n  onTrackTelemetry?: Track;\n}\n\ninterface EntryInput<P extends LearningComponentProps> {\n  /** Showcase heading. */\n  title: string;\n  /** One or two sentences for the showcase. */\n  description: string;\n  Component: ComponentType<P>;\n  /** Example payload. Doubles as the showcase demo and a parser fixture. */\n  demo: P;\n  /**\n   * Validates one streamed `<LEARNING_COMPONENT>` payload into props, or null\n   * if it does not conform. This is a trust boundary: the input is model\n   * output, so every field is checked rather than assumed.\n   *\n   * `activityId` is supplied by the parser because the LLM schema has no id\n   * field, but xAPI statements need a stable activity to point at.\n   */\n  parse: (data: Record<string, unknown>, activityId: string) => P | null;\n}\n\nconst isNonEmptyString = (v: unknown): v is string => typeof v === \"string\" && v.trim() !== \"\";\n\n/**\n * Binds a component to its demo payload while the prop type is still concrete.\n * Iterating the registry widens entries to a union, at which point TypeScript\n * can no longer tell that a given demo belongs to a given component — so the\n * pairing is resolved into `render` here instead of being cast at the call site.\n */\nconst entry = <P extends LearningComponentProps>(e: EntryInput<P>) => ({\n  title: e.title,\n  description: e.description,\n  Component: e.Component,\n  demo: e.demo,\n  parse: e.parse,\n  render: (onTrackTelemetry?: Track): ReactNode => (\n    <e.Component {...e.demo} onTrackTelemetry={onTrackTelemetry} />\n  ),\n  /**\n   * Validates and renders one streamed payload, or returns null if it does not\n   * conform. Combined into one step for the same union-widening reason as\n   * `render`: only here is P concrete enough to pass parsed props to Component.\n   */\n  parseAndRender: (\n    data: Record<string, unknown>,\n    activityId: string,\n    onTrackTelemetry?: Track\n  ): ReactNode | null => {\n    const props = e.parse(data, activityId);\n    return props ? <e.Component {...props} onTrackTelemetry={onTrackTelemetry} /> : null;\n  },\n});\n\n/**\n * Single source of truth for the library. Adding a component means adding one\n * entry here: the showcase page and the `<LEARNING_COMPONENT>` stream parser\n * both read from this map rather than keeping their own lists.\n *\n * Keys are the `type` field of the JSON an LLM emits.\n */\nexport const registry = {\n  quiz: entry({\n    title: \"Quiz\",\n    description:\n      \"An inline concept check with immediate feedback. Emits an xAPI “answered” statement on submit — open the console to see it.\",\n    Component: QuizBlock,\n    parse: (data, activityId) => {\n      if (!isNonEmptyString(data.prompt) || !Array.isArray(data.options)) return null;\n      // A quiz with no options, or with exactly one, is not answerable.\n      if (data.options.length < 2) return null;\n      const options = data.options.map((o: unknown) => {\n        const opt = o as Record<string, unknown>;\n        if (!isNonEmptyString(opt?.id) || !isNonEmptyString(opt?.text)) return null;\n        return {\n          id: opt.id,\n          text: opt.text,\n          valid: opt.valid === true,\n          feedback: typeof opt.feedback === \"string\" ? opt.feedback : \"\",\n        };\n      });\n      if (options.some((o) => o === null)) return null;\n      const valid = options as NonNullable<(typeof options)[number]>[];\n      // Without a correct answer the feedback and the xAPI `success` flag are\n      // both meaningless, so treat it as malformed rather than render it.\n      if (!valid.some((o) => o.valid)) return null;\n      return { activityId, prompt: data.prompt, options: valid };\n    },\n    demo: {\n      activityId: \"http-idempotency-check\",\n      prompt: \"Which HTTP method should be safely designed as idempotent?\",\n      options: [\n        {\n          id: \"A\",\n          text: \"POST\",\n          valid: false,\n          feedback: \"POST creates new resources and is typically non-idempotent.\",\n        },\n        {\n          id: \"B\",\n          text: \"GET\",\n          valid: true,\n          feedback: \"Correct! Multiple GET calls return the same data without altering state.\",\n        },\n      ],\n    },\n  }),\n  flashcard: entry({\n    title: \"Flashcard\",\n    description:\n      \"A term on the front, the explanation on the back. Emits an xAPI “experienced” statement the first time it is revealed.\",\n    Component: FlashcardBlock,\n    parse: (data, activityId) =>\n      isNonEmptyString(data.front) && isNonEmptyString(data.back)\n        ? { activityId, front: data.front, back: data.back }\n        : null,\n    demo: {\n      activityId: \"idempotency-definition\",\n      front: \"What does it mean for an HTTP method to be idempotent?\",\n      back: \"Making the same request many times has the same effect as making it once.\",\n    },\n  }),\n  sequence: entry({\n    title: \"Sequence builder\",\n    description:\n      \"Steps arrive shuffled and the learner drags them into order. Pointer, touch, and keyboard reach the same interaction, and submitting emits an xAPI “answered” statement carrying the submitted order.\",\n    Component: SequenceBuilder,\n    parse: (data, activityId) => {\n      if (!isNonEmptyString(data.prompt) || !Array.isArray(data.steps)) return null;\n      // Two steps is a coin flip, not an ordering task — and the anti-identity\n      // rotation in the component would make the answer the reverse every time.\n      if (data.steps.length < 3) return null;\n      const steps = data.steps.map((s: unknown) => {\n        const step = s as Record<string, unknown>;\n        if (!isNonEmptyString(step?.id) || !isNonEmptyString(step?.text)) return null;\n        return { id: step.id, text: step.text };\n      });\n      if (steps.some((s) => s === null)) return null;\n      const valid = steps as NonNullable<(typeof steps)[number]>[];\n      // Duplicate ids would make the submitted order ambiguous in the xAPI\n      // response and break React's keys.\n      if (new Set(valid.map((s) => s.id)).size !== valid.length) return null;\n      return { activityId, prompt: data.prompt, steps: valid };\n    },\n    demo: {\n      activityId: \"http-request-lifecycle\",\n      prompt: \"Order the steps of an HTTP request.\",\n      steps: [\n        { id: \"dns\", text: \"Resolve the hostname via DNS\" },\n        { id: \"tcp\", text: \"Open a TCP connection\" },\n        { id: \"tls\", text: \"Negotiate TLS\" },\n        { id: \"send\", text: \"Send the request\" },\n        { id: \"recv\", text: \"Receive the response\" },\n      ],\n    },\n  }),\n  mastery: entry({\n    title: \"Mastery meter\",\n    description:\n      \"The tutor's read on where the learner stands across a topic, plus a self-rating that emits an xAPI “rated” statement — the signal for what to teach next.\",\n    Component: MasteryMeter,\n    parse: (data, activityId) => {\n      if (!isNonEmptyString(data.title) || !Array.isArray(data.skills)) return null;\n      if (data.skills.length === 0) return null;\n      const skills = data.skills.map((s: unknown) => {\n        const skill = s as Record<string, unknown>;\n        if (!isNonEmptyString(skill?.id) || !isNonEmptyString(skill?.label)) return null;\n        // Out-of-range levels would overflow the bar and print figures like\n        // \"340%\". Reject rather than clamp: a model that far off the schema is\n        // probably wrong about the content too.\n        const level = skill.level;\n        if (typeof level !== \"number\" || !Number.isFinite(level) || level < 0 || level > 1) {\n          return null;\n        }\n        return { id: skill.id, label: skill.label, level };\n      });\n      if (skills.some((s) => s === null)) return null;\n      const valid = skills as NonNullable<(typeof skills)[number]>[];\n      if (new Set(valid.map((s) => s.id)).size !== valid.length) return null;\n      return { activityId, title: data.title, skills: valid };\n    },\n    demo: {\n      activityId: \"rest-fundamentals\",\n      title: \"REST fundamentals\",\n      skills: [\n        { id: \"methods\", label: \"HTTP methods\", level: 0.8 },\n        { id: \"idempotency\", label: \"Idempotency\", level: 0.6 },\n        { id: \"status\", label: \"Status codes\", level: 0.4 },\n        { id: \"caching\", label: \"Caching headers\", level: 0.2 },\n      ],\n    },\n  }),\n};\n\nexport type LearningType = keyof typeof registry;\n",
      "type": "registry:lib",
      "target": "@components/learning/registry.tsx"
    },
    {
      "path": "components/learning/LearningStream.tsx",
      "content": "\"use client\";\nimport { ComponentBoundary } from \"./ComponentBoundary\";\nimport { parseLearningStream } from \"./parse\";\nimport { registry } from \"./registry\";\nimport type { XApiStatement } from \"./xapi\";\n\n/**\n * Exported so a proof surface can render the real fallback rather than a\n * lookalike: the boundary's fallback and the parser's are the same line, and a\n * copy of it would drift.\n */\nexport const Unavailable = () => (\n  <p\n    data-stream-segment=\"invalid\"\n    className=\"text-muted-foreground border-muted-foreground/30 rounded-lg border border-dashed p-3 text-xs\"\n  >\n    An activity could not be displayed.\n  </p>\n);\n\nexport interface LearningStreamProps {\n  /** Everything the model has emitted so far, not just the latest chunk. */\n  text: string;\n  onTrackTelemetry?: (statement: XApiStatement) => void;\n}\n\n/**\n * Renders a model's output, hydrating `<LEARNING_COMPONENT>` payloads into live\n * components as they arrive. Components render inline in the host layout — no\n * iframe, no post-processing pass.\n */\nexport function LearningStream({ text, onTrackTelemetry }: LearningStreamProps) {\n  const segments = parseLearningStream(text);\n\n  return (\n    <div className=\"w-full space-y-4\">\n      {segments.map((segment, i) => {\n        switch (segment.kind) {\n          case \"text\":\n            return (\n              <p key={`text-${i}`} data-stream-segment=\"text\" className=\"text-sm leading-relaxed\">\n                {segment.text}\n              </p>\n            );\n\n          case \"component\":\n            return (\n              // Keyed by activity id, not array index: the id is stable as more\n              // of the stream arrives, so a learner's in-progress answer is not\n              // reset by later chunks remounting the component.\n              <div\n                key={segment.activityId}\n                data-stream-segment=\"component\"\n                className=\"flex justify-center py-1\"\n              >\n                <ComponentBoundary fallback={<Unavailable />}>\n                  {registry[segment.type].parseAndRender(\n                    segment.data,\n                    segment.activityId,\n                    onTrackTelemetry\n                  )}\n                </ComponentBoundary>\n              </div>\n            );\n\n          case \"invalid\":\n            return <Unavailable key={segment.activityId} />;\n\n          case \"pending\":\n            return (\n              <div\n                key=\"pending\"\n                data-stream-segment=\"pending\"\n                aria-hidden\n                className=\"bg-card mx-auto w-full max-w-md animate-pulse rounded-xl border p-4 shadow-sm\"\n              >\n                <div className=\"bg-muted mb-3 h-3 w-24 rounded\" />\n                <div className=\"bg-muted mb-2 h-3 w-full rounded\" />\n                <div className=\"bg-muted h-8 w-full rounded-lg\" />\n              </div>\n            );\n        }\n      })}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/learning/LearningStream.tsx"
    }
  ],
  "type": "registry:component"
}
