{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "course-1",
  "title": "Course 1: curriculum and progress",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A course page: a hero with a progress ring and a Continue button for the next lesson, expandable modules with their own progress, lessons you can open or mark done, locked ones with the reason, and a certificate that unlocks at the end.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/cover-art.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/course-1/course-1.tsx",
      "content": "// Ballmac UI: Course 1. https://ui.ballmac.com/blocks/course-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { Award, BookOpen, CircleCheck, ClipboardCheck, ChevronDown, Hammer, Lock, Play } from \"lucide-react\"\n\nimport { Button } from \"@/components/ballmac/button\"\nimport { CoverArt } from \"@/components/ballmac/cover-art\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { cn } from \"@/lib/utils\"\n\ntype Course1Kind = \"video\" | \"reading\" | \"quiz\" | \"project\"\ntype Course1Lesson = { id: string; title: string; kind: Course1Kind; minutes: number; done?: boolean; /** Why it cannot be opened yet, shown instead of the length. */ locked?: string }\ntype Course1Module = { id: string; title: string; lessons: Course1Lesson[] }\n\ntype Course1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    title?: string\n    subtitle?: string\n    instructor?: { name: string; role: string }\n    level?: string\n    modules?: Course1Module[]\n    /** The module open at first. Defaults to the one with the next lesson. */\n    defaultOpenId?: string\n    /** Called when someone opens a lesson, or presses Continue. */\n    onOpenLesson?: (lesson: Course1Lesson) => void\n    /** Called when someone marks a lesson done or not done. */\n    onToggleDone?: (lesson: Course1Lesson, done: boolean) => void\n  }\n\nconst sample: Course1Module[] = [\n  { id: \"m1\", title: \"Start here\", lessons: [{ id: \"l1\", title: \"Welcome and how this course works\", kind: \"video\", minutes: 4, done: true }, { id: \"l2\", title: \"Set up your workspace\", kind: \"video\", minutes: 11, done: true }, { id: \"l3\", title: \"The one idea behind everything\", kind: \"reading\", minutes: 8, done: true }] },\n  { id: \"m2\", title: \"Planning that holds up\", lessons: [{ id: \"l4\", title: \"Break a goal into work you can finish\", kind: \"video\", minutes: 14, done: true }, { id: \"l5\", title: \"Estimating without guessing\", kind: \"video\", minutes: 18 }, { id: \"l6\", title: \"Dependencies and what blocks you\", kind: \"reading\", minutes: 9 }, { id: \"l7\", title: \"Check your understanding\", kind: \"quiz\", minutes: 6 }] },\n  { id: \"m3\", title: \"Running the work\", lessons: [{ id: \"l8\", title: \"Weekly rhythm that people keep\", kind: \"video\", minutes: 16 }, { id: \"l9\", title: \"Meetings that end early\", kind: \"video\", minutes: 12 }, { id: \"l10\", title: \"Reporting without a slide deck\", kind: \"reading\", minutes: 10 }] },\n  { id: \"m4\", title: \"Final project\", lessons: [{ id: \"l11\", title: \"Plan a real project from scratch\", kind: \"project\", minutes: 45, locked: \"Finish the modules before it\" }, { id: \"l12\", title: \"Final check\", kind: \"quiz\", minutes: 10, locked: \"Finish the project first\" }] },\n]\n\nconst kindMeta: Record<Course1Kind, { label: string; icon: typeof Play }> = {\n  video: { label: \"Video\", icon: Play },\n  reading: { label: \"Reading\", icon: BookOpen },\n  quiz: { label: \"Quiz\", icon: ClipboardCheck },\n  project: { label: \"Project\", icon: Hammer },\n}\n\nconst dur = (m: number) => (m >= 60 ? `${Math.floor(m / 60)} h ${m % 60 ? `${m % 60} min` : \"\"}`.trim() : `${m} min`)\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"grid gap-5\">\n      <Skeleton className=\"h-64 rounded-3xl\" />\n      {[0, 1, 2].map((i) => (\n        <Skeleton key={i} className=\"h-20 rounded-2xl\" />\n      ))}\n    </div>\n  )\n}\n\nfunction Ring({ value }: { value: number }) {\n  const r = 42\n  const c = 2 * Math.PI * r\n  return (\n    <svg aria-hidden=\"true\" viewBox=\"0 0 100 100\" className=\"size-28 -rotate-90 rtl:rotate-90\">\n      <circle cx=\"50\" cy=\"50\" r={r} fill=\"none\" strokeWidth=\"9\" className=\"stroke-background/20\" />\n      <circle cx=\"50\" cy=\"50\" r={r} fill=\"none\" strokeWidth=\"9\" strokeLinecap=\"round\" strokeDasharray={c} strokeDashoffset={c * (1 - value)} className=\"stroke-background transition-[stroke-dashoffset] duration-700 ease-out motion-reduce:transition-none\" />\n    </svg>\n  )\n}\n\n/**\n * A course page: a hero with your progress ring and a Continue button that goes to the next lesson, then the modules as expandable cards,\n * each with its own progress and lessons you can open, mark done or see are locked (with the reason). Progress, time left and the\n * certificate unlock are worked out from the lessons.\n */\nfunction Course1({\n  title = \"Plan work that actually gets done\",\n  subtitle = \"A practical course for team leads: turn goals into plans, run the week, and report without slides.\",\n  instructor = { name: \"Dr. Elena Marsh\", role: \"Operations lead, 15 years\" },\n  level = \"Beginner friendly\",\n  modules = sample,\n  defaultOpenId,\n  onOpenLesson,\n  onToggleDone,\n  state = \"ready\",\n  onRetry,\n  emptyState,\n  errorState,\n  className,\n  ...props\n}: Course1Props) {\n  const id = React.useId()\n  const list = state === \"empty\" ? [] : modules\n  const [done, setDone] = React.useState<Set<string>>(() => new Set(modules.flatMap((m) => m.lessons.filter((l) => l.done).map((l) => l.id))))\n  const lessons = list.flatMap((m) => m.lessons)\n  const total = lessons.length\n  const doneCount = lessons.filter((l) => done.has(l.id)).length\n  const minutes = lessons.reduce((n, l) => n + l.minutes, 0)\n  const left = lessons.filter((l) => !done.has(l.id)).reduce((n, l) => n + l.minutes, 0)\n  const next = lessons.find((l) => !done.has(l.id) && !l.locked)\n  const nextModule = next ? list.find((m) => m.lessons.includes(next)) : undefined\n  const [open, setOpen] = React.useState<Set<string>>(() => new Set([defaultOpenId ?? list.find((m) => m.lessons.some((l) => !l.done && !l.locked))?.id ?? list[0]?.id ?? \"\"]))\n  const frac = total ? doneCount / total : 0\n  const complete = total > 0 && doneCount === total\n\n  const toggle = (l: Course1Lesson) => {\n    const on = !done.has(l.id)\n    setDone((d) => {\n      const n = new Set(d)\n      if (on) n.add(l.id)\n      else n.delete(l.id)\n      return n\n    })\n    onToggleDone?.(l, on)\n  }\n\n  const content = !total ? (\n    <div className=\"bg-card rounded-2xl border px-6 py-16 text-center\">\n      <span aria-hidden=\"true\" className=\"bg-muted mx-auto flex size-12 items-center justify-center rounded-full\">\n        <BookOpen className=\"size-6\" />\n      </span>\n      <h2 className=\"mt-5 text-lg font-semibold tracking-tight\">{emptyState?.title ?? \"No lessons yet\"}</h2>\n      <p className=\"text-muted-foreground mx-auto mt-1.5 max-w-xs text-sm text-pretty\">{emptyState?.description ?? \"The first lessons are on their way.\"}</p>\n      {emptyState?.action}\n    </div>\n  ) : (\n    <>\n      <div className=\"relative isolate overflow-hidden rounded-3xl border\">\n        <CoverArt seed={title} variant=\"mesh\" className=\"absolute inset-0 -z-20 scale-110 saturate-150\" />\n        <div aria-hidden=\"true\" className=\"from-foreground via-foreground/85 to-foreground/40 absolute inset-0 -z-10 bg-gradient-to-r rtl:bg-gradient-to-l\" />\n        <div className=\"text-background grid gap-8 p-6 sm:p-10 md:grid-cols-[minmax(0,1fr)_auto] md:items-center\">\n          <div className=\"min-w-0\">\n            <p className=\"text-background/70 text-sm font-medium\">{level}</p>\n            <h1 id={`${id}-title`} className=\"mt-2 text-3xl font-semibold tracking-[-0.04em] text-balance sm:text-5xl\">\n              {title}\n            </h1>\n            <p className=\"text-background/75 mt-4 max-w-xl text-pretty sm:text-lg\">{subtitle}</p>\n            <p className=\"text-background/75 mt-5 text-sm\">\n              With <span className=\"text-background font-medium\">{instructor.name}</span>, {instructor.role}\n            </p>\n            <p className=\"text-background/75 mt-1 text-sm tabular-nums\">\n              {total} lessons · {dur(minutes)} in all\n            </p>\n            <div className=\"mt-8 flex flex-wrap items-center gap-3\">\n              {complete ? (\n                <p className=\"bg-background/15 flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium\">\n                  <Award aria-hidden=\"true\" className=\"size-4\" />\n                  Course complete. Your certificate is ready.\n                </p>\n              ) : next ? (\n                <Button variant=\"secondary\" size=\"lg\" onClick={() => onOpenLesson?.(next)}>\n                  <Play aria-hidden=\"true\" className=\"fill-current\" />\n                  {doneCount ? \"Continue\" : \"Start\"}: {next.title.length > 34 ? `${next.title.slice(0, 33)}…` : next.title}\n                  <span className=\"sr-only\"> ({nextModule?.title})</span>\n                </Button>\n              ) : (\n                <p className=\"text-background/75 text-sm\">Finish the open lessons to unlock the rest.</p>\n              )}\n            </div>\n          </div>\n          <div className=\"flex items-center gap-5 md:flex-col md:items-center md:gap-3\">\n            <div className=\"relative\">\n              <Ring value={frac} />\n              <span className=\"absolute inset-0 flex items-center justify-center text-2xl font-semibold tabular-nums\">{Math.round(frac * 100)}%</span>\n            </div>\n            <p className=\"text-background/75 text-sm md:text-center\">\n              <span className=\"text-background font-medium tabular-nums\">\n                {doneCount} of {total}\n              </span>{\" \"}\n              done\n              <br />\n              {complete ? \"Nothing left\" : <span className=\"tabular-nums\">{dur(left)} to go</span>}\n            </p>\n          </div>\n        </div>\n      </div>\n      <div className=\"sr-only\" role=\"progressbar\" aria-label=\"Course progress\" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(frac * 100)} />\n\n      <h2 className=\"mt-12 mb-5 text-xl font-semibold tracking-tight\">What you will learn</h2>\n      <ol className=\"grid gap-3\">\n        {list.map((m, mi) => {\n          const md = m.lessons.filter((l) => done.has(l.id)).length\n          const isOpen = open.has(m.id)\n          const mMinutes = m.lessons.reduce((n, l) => n + l.minutes, 0)\n          const all = md === m.lessons.length\n          return (\n            <li key={m.id} className=\"bg-card overflow-hidden rounded-2xl border\">\n              <h3>\n                <button\n                  type=\"button\"\n                  aria-expanded={isOpen}\n                  aria-controls={`${id}-${m.id}`}\n                  onClick={() => setOpen((s) => { const n = new Set(s); if (n.has(m.id)) n.delete(m.id); else n.add(m.id); return n })}\n                  className=\"hover:bg-accent/40 focus-visible:ring-ring/50 flex w-full items-center gap-4 px-5 py-4 text-start outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-inset motion-reduce:transition-none\"\n                >\n                  <span aria-hidden=\"true\" className={cn(\"flex size-9 shrink-0 items-center justify-center rounded-full border text-sm font-semibold tabular-nums\", all && \"bg-foreground text-background border-foreground\")}>\n                    {all ? <CircleCheck className=\"size-5\" /> : mi + 1}\n                  </span>\n                  <span className=\"min-w-0 flex-1\">\n                    <span className=\"block font-semibold tracking-tight\">{m.title}</span>\n                    <span className=\"text-muted-foreground mt-0.5 block text-sm tabular-nums\">\n                      {md} of {m.lessons.length} done · {dur(mMinutes)}\n                    </span>\n                  </span>\n                  <span aria-hidden=\"true\" className=\"bg-muted hidden h-1.5 w-24 overflow-hidden rounded-full sm:block\">\n                    <span className=\"bg-foreground block h-full rounded-full transition-[width] duration-500 motion-reduce:transition-none\" style={{ width: `${(md / m.lessons.length) * 100}%` }} />\n                  </span>\n                  <ChevronDown aria-hidden=\"true\" className={cn(\"text-muted-foreground size-5 shrink-0 transition-transform motion-reduce:transition-none\", isOpen && \"rotate-180\")} />\n                </button>\n              </h3>\n              {isOpen ? (\n                <ul id={`${id}-${m.id}`} className=\"divide-y border-t\">\n                  {m.lessons.map((l) => {\n                    const K = kindMeta[l.kind]\n                    const d = done.has(l.id)\n                    return (\n                      <li key={l.id} className=\"flex items-center gap-3 px-5 py-3 sm:gap-4\">\n                        <button\n                          type=\"button\"\n                          role=\"checkbox\"\n                          aria-checked={d}\n                          aria-label={`Mark \"${l.title}\" as ${d ? \"not done\" : \"done\"}`}\n                          disabled={!!l.locked}\n                          onClick={() => toggle(l)}\n                          className={cn(\"focus-visible:ring-ring/50 flex size-7 shrink-0 items-center justify-center rounded-full border outline-none transition-colors focus-visible:ring-[3px] disabled:opacity-40 motion-reduce:transition-none\", d ? \"bg-foreground text-background border-foreground\" : \"hover:bg-accent\")}\n                        >\n                          {l.locked ? <Lock aria-hidden=\"true\" className=\"size-3.5\" /> : d ? <CircleCheck aria-hidden=\"true\" className=\"size-4\" /> : null}\n                        </button>\n                        <button type=\"button\" disabled={!!l.locked} onClick={() => onOpenLesson?.(l)} className=\"hover:bg-accent/50 focus-visible:ring-ring/50 -my-1 flex min-w-0 flex-1 items-center gap-3 rounded-lg px-2 py-1.5 text-start outline-none transition-colors focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-transparent motion-reduce:transition-none\">\n                          <K.icon aria-hidden=\"true\" className=\"text-muted-foreground size-4 shrink-0\" />\n                          <span className=\"min-w-0 flex-1\">\n                            <span className={cn(\"block truncate text-sm font-medium\", d && \"text-muted-foreground\")}>{l.title}</span>\n                            <span className=\"text-muted-foreground block text-xs\">\n                              {K.label}\n                              {l.locked ? ` · ${l.locked}` : \"\"}\n                            </span>\n                          </span>\n                          <span className=\"text-muted-foreground text-xs tabular-nums\">{dur(l.minutes)}</span>\n                        </button>\n                      </li>\n                    )\n                  })}\n                </ul>\n              ) : null}\n            </li>\n          )\n        })}\n      </ol>\n\n      <div className={cn(\"mt-8 flex items-center gap-4 rounded-2xl border p-5\", complete ? \"bg-chart-2/10 border-chart-2/30\" : \"border-dashed\")}>\n        <span aria-hidden=\"true\" className=\"bg-muted flex size-11 shrink-0 items-center justify-center rounded-full\">\n          {complete ? <Award className=\"size-5\" /> : <Lock className=\"size-5\" />}\n        </span>\n        <p className=\"text-sm text-pretty\">\n          <span className=\"font-semibold\">Certificate of completion</span>\n          <span className=\"text-muted-foreground block\">{complete ? \"You finished every lesson. Download it from your profile.\" : `Unlocks when you finish all ${total} lessons. ${total - doneCount} to go.`}</span>\n        </p>\n      </div>\n    </>\n  )\n\n  return (\n    <section data-slot=\"course-1\" data-state={state} aria-labelledby={total && state === \"ready\" ? `${id}-title` : undefined} aria-label={total && state === \"ready\" ? undefined : \"Course\"} className={cn(\"mx-auto w-full max-w-4xl px-4 py-10 sm:px-6\", className)} {...frameProps(props)}>\n      <StateView state={state === \"loading\" || state === \"error\" ? state : \"ready\"} onRetry={onRetry} label=\"Loading the course\" loading={<Skel />} error={{ title: errorState?.title ?? \"The course could not be loaded\", description: errorState?.description ?? \"Your progress is safe. Check your connection and try again.\", ...errorState }}>\n        {content}\n      </StateView>\n    </section>\n  )\n}\n\nexport { Course1, type Course1Props, type Course1Module, type Course1Lesson, type Course1Kind }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/course-1/course-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "course",
      "curriculum",
      "lessons",
      "learning",
      "progress",
      "modules",
      "lms",
      "education",
      "certificate",
      "training"
    ],
    "version": "1.0.0",
    "updated": "2026-10-18",
    "tests": true,
    "ai": {
      "summary": "A course page. <Course1 title subtitle instructor={{ name, role }} level modules={[{ id, title, lessons: [{ id, title, kind: 'video' | 'reading' | 'quiz' | 'project', minutes, done, locked: 'reason' }] }]} defaultOpenId onOpenLesson={(lesson) => ...} onToggleDone={(lesson, done) => ...} />. Progress, the ring, time left, the next lesson for Continue and the certificate unlock are worked out from the lessons. A locked lesson is disabled and says why.",
      "whenToUse": [
        "A course landing and progress page in a learning product",
        "Onboarding academies and training programs"
      ],
      "whenNotToUse": [
        "A single lesson player",
        "A list of courses to browse (use a card grid)"
      ],
      "composesWith": [
        "app-shell-1",
        "profile-1",
        "onboarding-1"
      ],
      "a11y": [
        {
          "keys": "Tab, Enter / Space",
          "action": "Each module heading is a button (expanded state announced); each lesson has its own done checkbox and an open button"
        },
        {
          "keys": "Progress",
          "action": "A labelled progress bar and 'N of M done' in text; the ring is decorative"
        },
        {
          "keys": "Locked",
          "action": "Disabled with the reason written beside the type, such as 'Quiz · Finish the project first'"
        }
      ],
      "customization": [
        "Lesson kinds: video, reading, quiz, project, each with its icon and word",
        "Continue goes to the first lesson that is not done and not locked",
        "The ring and the module bars fill with a short transition that is off for reduced motion"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "course-1-loading",
        "trigger": "state=\"loading\"",
        "note": "A skeleton hero and module cards, announced as loading."
      },
      {
        "id": "empty",
        "title": "No lessons yet",
        "example": "course-1-empty",
        "trigger": "state=\"empty\" (or modules={[]})",
        "note": "Says the first lessons are on their way; emptyState sets the copy."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "course-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert saying progress is safe, with Try again (onRetry)."
      }
    ],
    "examples": [
      "course-1-demo",
      "course-1-new",
      "course-1-complete",
      "course-1-loading",
      "course-1-empty",
      "course-1-error"
    ],
    "url": "https://ui.ballmac.com/components/course-1"
  },
  "categories": [
    "blocks",
    "course"
  ],
  "type": "registry:block"
}