Beadkit

Beadkit — learning primitives for AI

Components an LLM can emit mid-stream, rendered natively with built-in xAPI telemetry.

npx shadcn@latest add https://beadkit.dev/r/learning-stream.json

That pulls the parser and every component it can render. Each section below installs on its own. Then give your model the protocol in llms.txt.

01

Streaming

Model output rendered as it arrives, hydrating <LEARNING_COMPONENT> payloads into live components inline — no iframe, no post-processing pass. A half-arrived payload shows a placeholder instead of leaking raw JSON.

npx shadcn@latest add https://beadkit.dev/r/learning-stream.json
Show source(3 files, 456 lines)
components/learning/parse.ts
import { registry, type LearningType } from "./registry";

const OPEN = "<LEARNING_COMPONENT>";
const CLOSE = "</LEARNING_COMPONENT>";

export type Segment =
  | { kind: "text"; text: string }
  /** Validated payload, ready to render. */
  | { kind: "component"; type: LearningType; data: Record<string, unknown>; activityId: string }
  /** Tag closed but the payload was malformed or of an unknown type. */
  | { kind: "invalid"; activityId: string }
  /** Tag opened and the stream has not closed it yet. */
  | { kind: "pending" };

/**
 * If the text ends mid-tag ("...<LEARNING_COMP"), that fragment is the start of
 * a component, not prose. Without this the raw tag characters flash on screen
 * as the stream arrives.
 */
function trimPartialOpenTag(text: string): string {
  for (let i = OPEN.length - 1; i > 0; i--) {
    if (text.endsWith(OPEN.slice(0, i))) return text.slice(0, -i);
  }
  return text;
}

/** The protocol forbids code fences inside the tags, but models add them anyway. */
function stripFences(raw: string): string {
  return raw
    .replace(/^\s*```(?:json)?\s*/i, "")
    .replace(/\s*```\s*$/, "")
    .trim();
}

function classify(raw: string, activityId: string): Segment {
  let data: unknown;
  try {
    data = JSON.parse(stripFences(raw));
  } catch {
    return { kind: "invalid", activityId };
  }
  if (typeof data !== "object" || data === null || Array.isArray(data)) {
    return { kind: "invalid", activityId };
  }
  const record = data as Record<string, unknown>;
  const type = record.type;
  if (typeof type !== "string" || !(type in registry)) {
    return { kind: "invalid", activityId };
  }
  const entry = registry[type as LearningType];
  // Validate now so the stream never hands a broken payload to a component.
  if (entry.parse(record, activityId) === null) {
    return { kind: "invalid", activityId };
  }
  return { kind: "component", type: type as LearningType, data: record, activityId };
}

/**
 * Splits accumulated LLM output into prose and learning components.
 *
 * Takes the full text so far rather than a delta, so it is pure and idempotent:
 * calling it on every chunk cannot drift out of sync with the stream the way an
 * incremental state machine can. Text after an unclosed tag is withheld as
 * `pending` — the payload is still arriving and cannot be trusted yet.
 */
export function parseLearningStream(text: string): Segment[] {
  const segments: Segment[] = [];
  const pushText = (t: string) => {
    if (t.trim()) segments.push({ kind: "text", text: t.trim() });
  };

  let rest = text;
  let index = 0;
  for (;;) {
    const openAt = rest.indexOf(OPEN);
    if (openAt === -1) {
      pushText(trimPartialOpenTag(rest));
      return segments;
    }
    pushText(rest.slice(0, openAt));

    const after = rest.slice(openAt + OPEN.length);
    const closeAt = after.indexOf(CLOSE);
    if (closeAt === -1) {
      // Still streaming. Anything after an unclosed tag is part of this payload.
      segments.push({ kind: "pending" });
      return segments;
    }
    // Ordinal, not a content hash: components are only ever appended, so an
    // earlier component keeps its id as more of the stream arrives, which keeps
    // xAPI statements pointing at a stable activity across re-renders.
    segments.push(classify(after.slice(0, closeAt), `streamed-${index}`));
    index++;
    rest = after.slice(closeAt + CLOSE.length);
  }
}

// Self-check: npx tsx components/learning/parse.ts
if (process.argv[1]?.endsWith("parse.ts")) {
  const kinds = (t: string) => parseLearningStream(t).map((s) => s.kind).join(",");
  const quiz = JSON.stringify({
    type: "quiz",
    prompt: "Which is idempotent?",
    options: [
      { id: "A", text: "POST", valid: false, feedback: "no" },
      { id: "B", text: "GET", valid: true, feedback: "yes" },
    ],
  });
  const wrap = (json: string) => `${OPEN}${json}${CLOSE}`;

  console.assert(kinds("just prose") === "text", "plain text");
  console.assert(kinds("") === "", "empty stream yields nothing");
  console.assert(kinds(`Intro ${wrap(quiz)} outro`) === "text,component,text", "text around");
  console.assert(kinds(`${wrap(quiz)}${wrap(quiz)}`) === "component,component", "back to back");

  // Cut-off mid-payload: withheld, never rendered half-parsed.
  console.assert(kinds(`Intro ${OPEN}{"type":"qu`) === "text,pending", "unclosed tag pends");
  console.assert(kinds(`Intro ${OPEN}`) === "text,pending", "bare open tag pends");

  // A partially-arrived open tag must not leak to the reader as prose.
  console.assert(kinds("Intro <LEARNING_COMP") === "text", "partial tag is not prose");
  console.assert(
    parseLearningStream("Intro <LEARNING_COMP")[0]?.kind === "text" &&
      (parseLearningStream("Intro <LEARNING_COMP")[0] as { text: string }).text === "Intro",
    "partial tag trimmed from text"
  );

  // Malformed payloads degrade to `invalid`, never throw.
  console.assert(kinds(wrap("{not json")) === "invalid", "corrupt json");
  console.assert(kinds(wrap("[1,2]")) === "invalid", "array payload");
  console.assert(kinds(wrap('{"type":"unknown"}')) === "invalid", "unknown type");
  console.assert(kinds(wrap('{"type":"quiz"}')) === "invalid", "quiz missing fields");
  console.assert(
    kinds(wrap('{"type":"quiz","prompt":"p","options":[{"id":"A","text":"a","valid":false}]}')) ===
      "invalid",
    "quiz with no correct answer"
  );
  console.assert(
    kinds(wrap('{"type":"flashcard","front":"f","back":""}')) === "invalid",
    "flashcard with empty back"
  );
  console.assert(
    kinds(wrap('{"type":"flashcard","front":"f","back":"b"}')) === "component",
    "valid flashcard"
  );
  console.assert(kinds(wrap("```json\n" + quiz + "\n```")) === "component", "code fences stripped");

  // The property that matters most: every prefix of a real stream is safe.
  const full = `Lead in.\n\n${wrap(quiz)}\n\nMiddle.\n\n${wrap(
    JSON.stringify({ type: "flashcard", front: "f", back: "b" })
  )}\n\nTail.`;
  for (let i = 0; i <= full.length; i++) {
    const segs = parseLearningStream(full.slice(0, i));
    console.assert(!segs.some((s) => s.kind === "invalid"), `prefix ${i} produced invalid`);
    const text = segs
      .filter((s): s is { kind: "text"; text: string } => s.kind === "text")
      .map((s) => s.text)
      .join(" ");
    console.assert(!text.includes("<LEARNING"), `prefix ${i} leaked a tag into prose`);
    console.assert(!text.includes('"type"'), `prefix ${i} leaked payload json into prose`);
  }
  console.assert(parseLearningStream(full).filter((s) => s.kind === "component").length === 2, "final");

  // Every registry demo must survive its own validator. This is what keeps the
  // documented schemas in PROMPT.md honest: the demos are the worked examples,
  // so a validator tightened without updating them fails here.
  for (const [type, e] of Object.entries(registry)) {
    const payload = JSON.stringify({ type, ...e.demo });
    console.assert(kinds(wrap(payload)) === "component", `registry demo "${type}" fails its parser`);
  }

  console.log("parse self-check passed");
}
components/learning/registry.tsx
import type { ComponentType, ReactNode } from "react";
import { FlashcardBlock } from "./FlashcardBlock";
import { MasteryMeter } from "./MasteryMeter";
import { QuizBlock } from "./QuizBlock";
import { SequenceBuilder } from "./SequenceBuilder";
import type { XApiStatement } from "./xapi";

type Track = (statement: XApiStatement) => void;

/** Props every learning component accepts. */
export interface LearningComponentProps {
  activityId: string;
  onTrackTelemetry?: Track;
}

interface EntryInput<P extends LearningComponentProps> {
  /** Showcase heading. */
  title: string;
  /** One or two sentences for the showcase. */
  description: string;
  Component: ComponentType<P>;
  /** Example payload. Doubles as the showcase demo and a parser fixture. */
  demo: P;
  /**
   * Validates one streamed `<LEARNING_COMPONENT>` payload into props, or null
   * if it does not conform. This is a trust boundary: the input is model
   * output, so every field is checked rather than assumed.
   *
   * `activityId` is supplied by the parser because the LLM schema has no id
   * field, but xAPI statements need a stable activity to point at.
   */
  parse: (data: Record<string, unknown>, activityId: string) => P | null;
}

const isNonEmptyString = (v: unknown): v is string => typeof v === "string" && v.trim() !== "";

/**
 * Binds a component to its demo payload while the prop type is still concrete.
 * Iterating the registry widens entries to a union, at which point TypeScript
 * can no longer tell that a given demo belongs to a given component — so the
 * pairing is resolved into `render` here instead of being cast at the call site.
 */
const entry = <P extends LearningComponentProps>(e: EntryInput<P>) => ({
  title: e.title,
  description: e.description,
  Component: e.Component,
  demo: e.demo,
  parse: e.parse,
  render: (onTrackTelemetry?: Track): ReactNode => (
    <e.Component {...e.demo} onTrackTelemetry={onTrackTelemetry} />
  ),
  /**
   * Validates and renders one streamed payload, or returns null if it does not
   * conform. Combined into one step for the same union-widening reason as
   * `render`: only here is P concrete enough to pass parsed props to Component.
   */
  parseAndRender: (
    data: Record<string, unknown>,
    activityId: string,
    onTrackTelemetry?: Track
  ): ReactNode | null => {
    const props = e.parse(data, activityId);
    return props ? <e.Component {...props} onTrackTelemetry={onTrackTelemetry} /> : null;
  },
});

/**
 * Single source of truth for the library. Adding a component means adding one
 * entry here: the showcase page and the `<LEARNING_COMPONENT>` stream parser
 * both read from this map rather than keeping their own lists.
 *
 * Keys are the `type` field of the JSON an LLM emits.
 */
export const registry = {
  quiz: entry({
    title: "Quiz",
    description:
      "An inline concept check with immediate feedback. Emits an xAPI “answered” statement on submit — open the console to see it.",
    Component: QuizBlock,
    parse: (data, activityId) => {
      if (!isNonEmptyString(data.prompt) || !Array.isArray(data.options)) return null;
      // A quiz with no options, or with exactly one, is not answerable.
      if (data.options.length < 2) return null;
      const options = data.options.map((o: unknown) => {
        const opt = o as Record<string, unknown>;
        if (!isNonEmptyString(opt?.id) || !isNonEmptyString(opt?.text)) return null;
        return {
          id: opt.id,
          text: opt.text,
          valid: opt.valid === true,
          feedback: typeof opt.feedback === "string" ? opt.feedback : "",
        };
      });
      if (options.some((o) => o === null)) return null;
      const valid = options as NonNullable<(typeof options)[number]>[];
      // Without a correct answer the feedback and the xAPI `success` flag are
      // both meaningless, so treat it as malformed rather than render it.
      if (!valid.some((o) => o.valid)) return null;
      return { activityId, prompt: data.prompt, options: valid };
    },
    demo: {
      activityId: "http-idempotency-check",
      prompt: "Which HTTP method should be safely designed as idempotent?",
      options: [
        {
          id: "A",
          text: "POST",
          valid: false,
          feedback: "POST creates new resources and is typically non-idempotent.",
        },
        {
          id: "B",
          text: "GET",
          valid: true,
          feedback: "Correct! Multiple GET calls return the same data without altering state.",
        },
      ],
    },
  }),
  flashcard: entry({
    title: "Flashcard",
    description:
      "A term on the front, the explanation on the back. Emits an xAPI “experienced” statement the first time it is revealed.",
    Component: FlashcardBlock,
    parse: (data, activityId) =>
      isNonEmptyString(data.front) && isNonEmptyString(data.back)
        ? { activityId, front: data.front, back: data.back }
        : null,
    demo: {
      activityId: "idempotency-definition",
      front: "What does it mean for an HTTP method to be idempotent?",
      back: "Making the same request many times has the same effect as making it once.",
    },
  }),
  sequence: entry({
    title: "Sequence builder",
    description:
      "Steps arrive shuffled and the learner orders them. Reordering is keyboard-driven, and submitting emits an xAPI “answered” statement carrying the submitted order.",
    Component: SequenceBuilder,
    parse: (data, activityId) => {
      if (!isNonEmptyString(data.prompt) || !Array.isArray(data.steps)) return null;
      // Two steps is a coin flip, not an ordering task — and the anti-identity
      // rotation in the component would make the answer the reverse every time.
      if (data.steps.length < 3) return null;
      const steps = data.steps.map((s: unknown) => {
        const step = s as Record<string, unknown>;
        if (!isNonEmptyString(step?.id) || !isNonEmptyString(step?.text)) return null;
        return { id: step.id, text: step.text };
      });
      if (steps.some((s) => s === null)) return null;
      const valid = steps as NonNullable<(typeof steps)[number]>[];
      // Duplicate ids would make the submitted order ambiguous in the xAPI
      // response and break React's keys.
      if (new Set(valid.map((s) => s.id)).size !== valid.length) return null;
      return { activityId, prompt: data.prompt, steps: valid };
    },
    demo: {
      activityId: "http-request-lifecycle",
      prompt: "Order the steps of an HTTP request.",
      steps: [
        { id: "dns", text: "Resolve the hostname via DNS" },
        { id: "tcp", text: "Open a TCP connection" },
        { id: "tls", text: "Negotiate TLS" },
        { id: "send", text: "Send the request" },
        { id: "recv", text: "Receive the response" },
      ],
    },
  }),
  mastery: entry({
    title: "Mastery meter",
    description:
      "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.",
    Component: MasteryMeter,
    parse: (data, activityId) => {
      if (!isNonEmptyString(data.title) || !Array.isArray(data.skills)) return null;
      if (data.skills.length === 0) return null;
      const skills = data.skills.map((s: unknown) => {
        const skill = s as Record<string, unknown>;
        if (!isNonEmptyString(skill?.id) || !isNonEmptyString(skill?.label)) return null;
        // Out-of-range levels would overflow the bar and print figures like
        // "340%". Reject rather than clamp: a model that far off the schema is
        // probably wrong about the content too.
        const level = skill.level;
        if (typeof level !== "number" || !Number.isFinite(level) || level < 0 || level > 1) {
          return null;
        }
        return { id: skill.id, label: skill.label, level };
      });
      if (skills.some((s) => s === null)) return null;
      const valid = skills as NonNullable<(typeof skills)[number]>[];
      if (new Set(valid.map((s) => s.id)).size !== valid.length) return null;
      return { activityId, title: data.title, skills: valid };
    },
    demo: {
      activityId: "rest-fundamentals",
      title: "REST fundamentals",
      skills: [
        { id: "methods", label: "HTTP methods", level: 0.8 },
        { id: "idempotency", label: "Idempotency", level: 0.6 },
        { id: "status", label: "Status codes", level: 0.4 },
        { id: "caching", label: "Caching headers", level: 0.2 },
      ],
    },
  }),
};

export type LearningType = keyof typeof registry;
components/learning/LearningStream.tsx
"use client";
import { ComponentBoundary } from "./ComponentBoundary";
import { parseLearningStream } from "./parse";
import { registry } from "./registry";
import type { XApiStatement } from "./xapi";

const Unavailable = () => (
  <p className="text-muted-foreground border-muted-foreground/30 rounded-lg border border-dashed p-3 text-xs">
    An activity could not be displayed.
  </p>
);

export interface LearningStreamProps {
  /** Everything the model has emitted so far, not just the latest chunk. */
  text: string;
  onTrackTelemetry?: (statement: XApiStatement) => void;
}

/**
 * Renders a model's output, hydrating `<LEARNING_COMPONENT>` payloads into live
 * components as they arrive. Components render inline in the host layout — no
 * iframe, no post-processing pass.
 */
export function LearningStream({ text, onTrackTelemetry }: LearningStreamProps) {
  const segments = parseLearningStream(text);

  return (
    <div className="w-full space-y-4">
      {segments.map((segment, i) => {
        switch (segment.kind) {
          case "text":
            return (
              <p key={`text-${i}`} className="text-sm leading-relaxed">
                {segment.text}
              </p>
            );

          case "component":
            return (
              // Keyed by activity id, not array index: the id is stable as more
              // of the stream arrives, so a learner's in-progress answer is not
              // reset by later chunks remounting the component.
              <div key={segment.activityId} className="flex justify-center py-1">
                <ComponentBoundary fallback={<Unavailable />}>
                  {registry[segment.type].parseAndRender(
                    segment.data,
                    segment.activityId,
                    onTrackTelemetry
                  )}
                </ComponentBoundary>
              </div>
            );

          case "invalid":
            return <Unavailable key={segment.activityId} />;

          case "pending":
            return (
              <div
                key="pending"
                aria-hidden
                className="bg-card mx-auto w-full max-w-md animate-pulse rounded-xl border p-4 shadow-sm"
              >
                <div className="bg-muted mb-3 h-3 w-24 rounded" />
                <div className="bg-muted mb-2 h-3 w-full rounded" />
                <div className="bg-muted h-8 w-full rounded-lg" />
              </div>
            );
        }
      })}
    </div>
  );
}
02

Quiz

An inline concept check with immediate feedback. Emits an xAPI “answered” statement on submit — open the console to see it.

Concept check

Which HTTP method should be safely designed as idempotent?

npx shadcn@latest add https://beadkit.dev/r/quiz.json
Show source(1 file, 100 lines)
components/learning/QuizBlock.tsx
"use client";
import { useState } from "react";
import { LearningCard } from "./LearningCard";
import { useTelemetry, type XApiStatement } from "./xapi";

export interface QuizOption {
  id: string;
  text: string;
  valid: boolean;
  feedback: string;
}

export interface QuizBlockProps {
  activityId: string;
  prompt: string;
  options: QuizOption[];
  onTrackTelemetry?: (statement: XApiStatement) => void;
}

export function QuizBlock({ activityId, prompt, options, onTrackTelemetry }: QuizBlockProps) {
  const [selected, setSelected] = useState<string | null>(null);
  const [submitted, setSubmitted] = useState(false);
  const track = useTelemetry(activityId, prompt, onTrackTelemetry);

  const chosen = options.find((o) => o.id === selected);

  const handleSubmit = () => {
    if (!chosen || submitted) return;
    setSubmitted(true);
    track("answered", { response: chosen.id, success: chosen.valid });
  };

  return (
    <LearningCard label="Concept check" type="quiz">
      <p id={`${activityId}-prompt`} className="mb-3 text-sm font-semibold">
        {prompt}
      </p>
      <fieldset
        role="radiogroup"
        aria-labelledby={`${activityId}-prompt`}
        disabled={submitted}
        className="space-y-2"
      >
        {options.map((opt) => {
          const isSelected = selected === opt.id;
          return (
            <label
              key={opt.id}
              className={`has-[:focus-visible]:ring-ring block w-full cursor-pointer rounded-lg border p-3 text-left text-xs transition-all has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-offset-1 ${
                isSelected
                  ? "border-primary bg-primary/5 font-medium"
                  : "hover:border-muted-foreground/40"
              } ${submitted ? "cursor-default opacity-80" : ""}`}
            >
              <input
                type="radio"
                name={`${activityId}-choice`}
                value={opt.id}
                checked={isSelected}
                onChange={() => setSelected(opt.id)}
                className="sr-only"
              />
              <span className="text-muted-foreground mr-1.5 font-mono">{opt.id}.</span>
              {opt.text}
            </label>
          );
        })}
      </fieldset>
      {selected && !submitted && (
        <button
          onClick={handleSubmit}
          className="bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring mt-3 w-full rounded-lg py-2 text-xs font-medium transition focus-visible:ring-2 focus-visible:ring-offset-1"
        >
          Submit answer
        </button>
      )}
      <div aria-live="polite">
        {submitted && chosen && (
          <div
            className={`mt-3 rounded-lg border p-3 text-xs ${
              chosen.valid
                ? "border-success/30 bg-success/10"
                : "border-destructive/30 bg-destructive/10"
            }`}
          >
            <span
              className={`mb-1 block font-bold ${
                chosen.valid ? "text-success" : "text-destructive"
              }`}
            >
              {chosen.valid ? "Correct" : "Incorrect"}
            </span>
            {chosen.feedback}
          </div>
        )}
      </div>
    </LearningCard>
  );
}
03

Flashcard

A term on the front, the explanation on the back. Emits an xAPI “experienced” statement the first time it is revealed.

Flashcard

Click to reveal the answer

npx shadcn@latest add https://beadkit.dev/r/flashcard.json
Show source(1 file, 53 lines)
components/learning/FlashcardBlock.tsx
"use client";
import { useState } from "react";
import { LearningCard } from "./LearningCard";
import { useTelemetry, type XApiStatement } from "./xapi";

export interface FlashcardBlockProps {
  activityId: string;
  front: string;
  back: string;
  onTrackTelemetry?: (statement: XApiStatement) => void;
}

export function FlashcardBlock({
  activityId,
  front,
  back,
  onTrackTelemetry,
}: FlashcardBlockProps) {
  const [revealed, setRevealed] = useState(false);
  const [tracked, setTracked] = useState(false);
  const track = useTelemetry(activityId, front, onTrackTelemetry);

  const flip = () => {
    setRevealed((v) => !v);
    // Only the first reveal is a learning event; flipping back and forth is not
    // repeated engagement and would inflate the LRS record.
    if (!tracked) {
      setTracked(true);
      track("experienced");
    }
  };

  return (
    <LearningCard label="Flashcard" type="flashcard">
      <button
        onClick={flip}
        aria-expanded={revealed}
        className="focus-visible:ring-ring hover:border-muted-foreground/40 flex min-h-24 w-full flex-col items-center justify-center gap-2 rounded-lg border p-4 text-center transition-all focus-visible:ring-2 focus-visible:ring-offset-1"
      >
        <span className="text-muted-foreground font-mono text-[10px] font-bold uppercase tracking-wider">
          {revealed ? "Back" : "Front"}
        </span>
        <span aria-live="polite" className="text-sm font-medium">
          {revealed ? back : front}
        </span>
      </button>
      <p className="text-muted-foreground mt-2 text-center text-[11px]">
        {revealed ? "Click to flip back" : "Click to reveal the answer"}
      </p>
    </LearningCard>
  );
}
04

Sequence builder

Steps arrive shuffled and the learner orders them. Reordering is keyboard-driven, and submitting emits an xAPI “answered” statement carrying the submitted order.

Put it in order

Order the steps of an HTTP request.

  1. 1Negotiate TLS
  2. 2Receive the response
  3. 3Resolve the hostname via DNS
  4. 4Send the request
  5. 5Open a TCP connection
npx shadcn@latest add https://beadkit.dev/r/sequence.json
Show source(1 file, 169 lines)
components/learning/SequenceBuilder.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { LearningCard } from "./LearningCard";
import { useTelemetry, type XApiStatement } from "./xapi";

export interface SequenceStep {
  id: string;
  text: string;
}

export interface SequenceBuilderProps {
  activityId: string;
  prompt: string;
  /** The correct order. Shown shuffled; the learner reorders back to this. */
  steps: SequenceStep[];
  onTrackTelemetry?: (statement: XApiStatement) => void;
}

/**
 * Deterministic shuffle: same seed, same order, every time.
 *
 * `Math.random()` here would produce one order on the server and another in the
 * browser, which is a hydration mismatch. Seeding from the activity id keeps the
 * function pure, so both sides agree.
 */
function shuffle(steps: SequenceStep[], seed: string): SequenceStep[] {
  let h = 2166136261;
  for (let i = 0; i < seed.length; i++) h = Math.imul(h ^ seed.charCodeAt(i), 16777619);

  const out = [...steps];
  for (let i = out.length - 1; i > 0; i--) {
    h = Math.imul(h ^ (h >>> 15), 2246822507);
    const j = (h >>> 0) % (i + 1);
    [out[i], out[j]] = [out[j], out[i]];
  }
  // A shuffle that lands on the answer hands the learner a solved activity.
  // Rotate rather than reshuffle: one step is enough to break it, and it always
  // terminates.
  return out.every((s, i) => s.id === steps[i].id) ? [...out.slice(1), out[0]] : out;
}

const btnId = (activityId: string, stepId: string, dir: "up" | "down") =>
  `${activityId}-${stepId}-${dir}`;

export function SequenceBuilder({
  activityId,
  prompt,
  steps,
  onTrackTelemetry,
}: SequenceBuilderProps) {
  const [order, setOrder] = useState(() => shuffle(steps, activityId));
  const [submitted, setSubmitted] = useState(false);
  const track = useTelemetry(activityId, prompt, onTrackTelemetry);

  // Reordering re-renders the list under the button that was just clicked, so
  // focus has to be put back by hand or every move drops the keyboard user out
  // of the list.
  const refocus = useRef<string | null>(null);
  useEffect(() => {
    if (!refocus.current) return;
    document.getElementById(refocus.current)?.focus();
    refocus.current = null;
  });

  const move = (from: number, delta: number) => {
    if (submitted) return;
    const to = from + delta;
    const next = [...order];
    [next[from], next[to]] = [next[to], next[from]];
    setOrder(next);
    // The button just clicked may not exist at the item's new position — an
    // item at the top has no "up" — so fall back to the opposite one.
    const up = delta < 0;
    const clickedStillExists = up ? to > 0 : to < next.length - 1;
    refocus.current = btnId(
      activityId,
      next[to].id,
      clickedStillExists ? (up ? "up" : "down") : up ? "down" : "up"
    );
  };

  const correct = order.every((s, i) => s.id === steps[i].id);

  const handleSubmit = () => {
    if (submitted) return;
    setSubmitted(true);
    track("answered", { response: order.map((s) => s.id).join(","), success: correct });
  };

  return (
    <LearningCard label="Put it in order" type="sequence">
      <p id={`${activityId}-prompt`} className="mb-3 text-sm font-semibold">
        {prompt}
      </p>

      <ol aria-labelledby={`${activityId}-prompt`} className="space-y-2">
        {order.map((step, i) => (
          <li
            key={step.id}
            className="flex items-center gap-2 rounded-lg border p-3 text-xs transition-all"
          >
            <span className="text-muted-foreground w-4 shrink-0 font-mono">{i + 1}</span>
            <span className="flex-1">{step.text}</span>
            {!submitted && (
              // Rendered only when the move is possible. A dimmed disabled
              // arrow would sit below 4.5:1 and fail the contrast check, and an
              // invisible one still takes tab focus.
              <span className="flex shrink-0 gap-0.5">
                <span className="w-6">
                  {i > 0 && (
                    <button
                      id={btnId(activityId, step.id, "up")}
                      onClick={() => move(i, -1)}
                      aria-label={`Move “${step.text}” up`}
                      className="text-muted-foreground hover:text-foreground focus-visible:ring-ring h-6 w-6 rounded transition focus-visible:ring-2"
                    >

                    </button>
                  )}
                </span>
                <span className="w-6">
                  {i < order.length - 1 && (
                    <button
                      id={btnId(activityId, step.id, "down")}
                      onClick={() => move(i, 1)}
                      aria-label={`Move “${step.text}” down`}
                      className="text-muted-foreground hover:text-foreground focus-visible:ring-ring h-6 w-6 rounded transition focus-visible:ring-2"
                    >

                    </button>
                  )}
                </span>
              </span>
            )}
          </li>
        ))}
      </ol>

      {!submitted && (
        <button
          onClick={handleSubmit}
          className="bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring mt-3 w-full rounded-lg py-2 text-xs font-medium transition focus-visible:ring-2 focus-visible:ring-offset-1"
        >
          Check order
        </button>
      )}

      <div aria-live="polite">
        {submitted && (
          <div
            className={`mt-3 rounded-lg border p-3 text-xs ${
              correct ? "border-success/30 bg-success/10" : "border-destructive/30 bg-destructive/10"
            }`}
          >
            <span
              className={`mb-1 block font-bold ${correct ? "text-success" : "text-destructive"}`}
            >
              {correct ? "Correct" : "Not quite"}
            </span>
            {correct
              ? "That is the right sequence."
              : `The right order is: ${steps.map((s) => s.text).join(" → ")}`}
          </div>
        )}
      </div>
    </LearningCard>
  );
}
05

Mastery meter

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.

Where you are

REST fundamentals

50%
  • HTTP methods80%
  • Idempotency60%
  • Status codes40%
  • Caching headers20%
How solid does this feel to you?
npx shadcn@latest add https://beadkit.dev/r/mastery.json
Show source(1 file, 130 lines)
components/learning/MasteryMeter.tsx
"use client";
import { useState } from "react";
import { LearningCard } from "./LearningCard";
import { useTelemetry, type XApiStatement } from "./xapi";

export interface MasterySkill {
  id: string;
  label: string;
  /** The model's assessment of the learner, 0 to 1. */
  level: number;
}

export interface MasteryMeterProps {
  activityId: string;
  title: string;
  skills: MasterySkill[];
  onTrackTelemetry?: (statement: XApiStatement) => void;
}

const RATINGS = [
  { id: "shaky", label: "Still shaky" },
  { id: "getting-there", label: "Getting there" },
  { id: "solid", label: "Solid" },
];

/** Segments per bar. Discrete reads as a level; a continuous fill reads as a
 *  loading state, which is the wrong idea for a mastery figure. */
const SEGMENTS = 5;

export function MasteryMeter({
  activityId,
  title,
  skills,
  onTrackTelemetry,
}: MasteryMeterProps) {
  const [rated, setRated] = useState<string | null>(null);
  const track = useTelemetry(activityId, title, onTrackTelemetry);

  const overall = skills.reduce((sum, s) => sum + s.level, 0) / skills.length;

  const rate = (id: string) => {
    if (rated) return;
    setRated(id);
    // No `success`: a self-rating is the learner's own read, not a scored
    // attempt, and an LRS treats a present `success` as one.
    track("rated", { response: id });
  };

  return (
    <LearningCard label="Where you are" type="mastery">
      <div className="mb-3 flex items-baseline justify-between gap-3">
        <p className="text-sm font-semibold">{title}</p>
        <span className="font-mono text-sm font-semibold">{Math.round(overall * 100)}%</span>
      </div>

      <ul className="space-y-2.5">
        {skills.map((skill) => {
          const filled = Math.round(skill.level * SEGMENTS);
          return (
            <li key={skill.id}>
              <div className="mb-1 flex items-baseline justify-between gap-3">
                <span className="text-xs">{skill.label}</span>
                <span className="text-muted-foreground font-mono text-[11px]">
                  {Math.round(skill.level * 100)}%
                </span>
              </div>
              <div
                className="flex gap-1"
                role="meter"
                aria-valuenow={filled}
                aria-valuemin={0}
                aria-valuemax={SEGMENTS}
                aria-label={skill.label}
              >
                {Array.from({ length: SEGMENTS }, (_, i) => (
                  <span
                    key={i}
                    // `bg-muted` is identical to `bg-card` in the dark palette,
                    // so the empty track has to come from a foreground alpha to
                    // stay visible in both modes.
                    className={`h-1.5 flex-1 rounded-full ${
                      i < filled ? "bg-primary" : "bg-muted-foreground/25"
                    }`}
                  />
                ))}
              </div>
            </li>
          );
        })}
      </ul>

      <fieldset className="mt-4" disabled={rated !== null}>
        <legend className="text-muted-foreground mb-2 text-xs">
          How solid does this feel to you?
        </legend>
        <div className="flex gap-1.5">
          {RATINGS.map((r) => (
            <label
              key={r.id}
              className={`has-[:focus-visible]:ring-ring flex-1 cursor-pointer rounded-lg border p-2 text-center text-[11px] transition-all has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-offset-1 ${
                rated === r.id
                  ? "border-primary bg-primary/5 font-medium"
                  : "hover:border-muted-foreground/40"
              } ${rated ? "cursor-default" : ""}`}
            >
              <input
                type="radio"
                name={`${activityId}-rating`}
                value={r.id}
                checked={rated === r.id}
                onChange={() => rate(r.id)}
                className="sr-only"
              />
              {r.label}
            </label>
          ))}
        </div>
      </fieldset>

      <div aria-live="polite">
        {rated && (
          <p className="text-muted-foreground mt-3 text-xs">
            Noted — your tutor will weight the next questions accordingly.
          </p>
        )}
      </div>
    </LearningCard>
  );
}