{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "empty-dashboard",
  "title": "Empty Dashboard",
  "description": "A first-run dashboard: welcome copy, a setup checklist whose progress counts done separately from skipped, a labelled skeleton of the filled dashboard, and a confirmed sample-data escape hatch — the block renders nothing once nothing is left to do.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/blocks/empty-dashboard.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowRight, Check, Database, Minus } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\n\n/**\n * \"skipped\" is deliberately a third status rather than a boolean on top of\n * \"done\": a user who skips \"invite your team\" has resolved that row, but the\n * account is NOT set up. Folding the two together is how onboarding progress\n * ends up lying about activation.\n */\nexport type OnboardingTaskStatus = \"todo\" | \"done\" | \"skipped\"\n\n/**\n * A task must ship a real destination — a route, or a handler. Supplying\n * neither is a type error, because a first-run checklist whose rows do nothing\n * is worse than no checklist at all.\n */\nexport type OnboardingTaskAction =\n  | { label: string; href: string; onClick?: () => void }\n  | { label: string; href?: undefined; onClick: () => void }\n\nexport interface OnboardingTask {\n  /** Stable identity — also what `onTaskStatusChange` reports back. Duplicates are dropped (first wins). */\n  id: string\n  title: string\n  /** One line on why this step matters. Wraps; never truncated. */\n  description?: string\n  status: OnboardingTaskStatus\n  /** Where the step actually happens. Rendered only while the task is \"todo\". */\n  action: OnboardingTaskAction\n  /** Set false for steps the product genuinely requires. Default: skippable. */\n  skippable?: boolean\n  /** Short effort hint, e.g. \"2 min\". */\n  estimateLabel?: string\n}\n\nexport interface EmptyDashboardSummary {\n  total: number\n  completed: number\n  skipped: number\n}\n\nexport interface EmptyDashboardProps extends Omit<React.ComponentPropsWithoutRef<\"section\">, \"title\"> {\n  /** The first-run task list. An empty array renders the welcome + preview without a checklist. */\n  tasks: OnboardingTask[]\n  title?: React.ReactNode\n  description?: React.ReactNode\n  /** Checklist heading; also the accessible name of the progress bar. */\n  checklistTitle?: string\n  /** Enables Skip / Undo. Omit for a read-only checklist driven entirely by your backend. */\n  onTaskStatusChange?: (taskId: string, next: OnboardingTaskStatus) => void\n  /**\n   * Fired once when no task is left in \"todo\". The block renders nothing from\n   * that moment on — a first-run panel that outlives first run is clutter.\n   */\n  onComplete?: (summary: EmptyDashboardSummary) => void\n  /** Given → renders the sample-data entry point (with its confirmation step). */\n  onLoadSampleData?: () => void\n  /** Whether sample data can be removed afterwards. Drives the confirmation copy. Default true. */\n  sampleDataReversible?: boolean\n  /** Skeleton preview of the filled dashboard. Default true. */\n  showPreview?: boolean\n}\n\ntype SamplePhase = \"idle\" | \"confirming\" | \"loaded\"\n\nconst FOCUS_RING =\n  \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n\nconst PRIMARY_BUTTON = cn(\n  \"inline-flex h-9 items-center gap-1.5 rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground\",\n  \"transition-colors hover:bg-primary/90\",\n  FOCUS_RING,\n)\n\nconst GHOST_BUTTON = cn(\n  \"inline-flex h-9 items-center gap-1.5 rounded-md border bg-card px-3 text-sm font-medium\",\n  \"transition-colors hover:bg-foreground/5\",\n  FOCUS_RING,\n)\n\nconst QUIET_BUTTON = cn(\n  \"inline-flex h-9 items-center rounded-md px-2 text-sm font-medium text-muted-foreground\",\n  \"transition-colors hover:bg-foreground/5 hover:text-foreground\",\n  FOCUS_RING,\n)\n\n/**\n * Placeholder geometry for the preview. Percentages, never labels — the preview\n * must not contain anything a reader could mistake for a real figure.\n */\nconst PREVIEW_BARS = [46, 74, 38, 88, 57, 69, 44, 81]\nconst PREVIEW_ROWS = [82, 64, 91, 55]\n\n/** Coalesce bursts of status changes into one announcement. */\nconst ANNOUNCE_DELAY = 700\n\n/** `#` and blank strings are dead links — degrade to plain text instead of faking a destination. */\nfunction isRealHref(href: string | undefined): href is string {\n  if (typeof href !== \"string\") return false\n  const trimmed = href.trim()\n  return trimmed.length > 0 && trimmed !== \"#\"\n}\n\nfunction StatusMarker({ index, status }: { index: number; status: OnboardingTaskStatus }) {\n  const base = \"mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full border text-xs font-medium\"\n\n  if (status === \"done\") {\n    return (\n      <span className={cn(base, \"border-primary bg-primary text-primary-foreground\")}>\n        <Check aria-hidden=\"true\" className=\"size-3.5\" />\n        <span className=\"sr-only\">Done</span>\n      </span>\n    )\n  }\n\n  if (status === \"skipped\") {\n    return (\n      <span className={cn(base, \"border-dashed border-muted-foreground/50 text-muted-foreground\")}>\n        <Minus aria-hidden=\"true\" className=\"size-3.5\" />\n        <span className=\"sr-only\">Skipped</span>\n      </span>\n    )\n  }\n\n  return (\n    <span className={cn(base, \"border-muted-foreground/40 tabular-nums text-muted-foreground\")}>\n      <span aria-hidden=\"true\">{index + 1}</span>\n      <span className=\"sr-only\">Not started</span>\n    </span>\n  )\n}\n\nfunction TaskAction({ action }: { action: OnboardingTaskAction }) {\n  const label = (\n    <>\n      {action.label}\n      <ArrowRight aria-hidden=\"true\" className=\"size-3.5\" />\n    </>\n  )\n\n  if (isRealHref(action.href)) {\n    return (\n      <a className={PRIMARY_BUTTON} href={action.href} onClick={action.onClick}>\n        {label}\n      </a>\n    )\n  }\n\n  if (action.onClick) {\n    return (\n      <button className={PRIMARY_BUTTON} onClick={action.onClick} type=\"button\">\n        {label}\n      </button>\n    )\n  }\n\n  // Neither a usable href nor a handler: render the intent as text rather than\n  // a button that does nothing.\n  return <span className=\"text-sm text-muted-foreground\">{action.label}</span>\n}\n\n/**\n * The \"what this looks like once it is full\" panel. Every shape is\n * `aria-hidden` geometry with no text at all, and the region carries two\n * visible sample markers — a badge in the header and a watermark over the\n * canvas — so a screenshot of it can never be mistaken for real reporting.\n */\nfunction DashboardPreview({ headingId }: { headingId: string }) {\n  return (\n    <section\n      aria-labelledby={headingId}\n      className=\"flex min-w-0 flex-col rounded-xl border bg-card p-5 text-card-foreground\"\n      data-slot=\"preview\"\n    >\n      <div className=\"flex flex-wrap items-center gap-2\">\n        <h3 className=\"text-sm font-semibold\" id={headingId}>\n          Preview\n        </h3>\n        <span className=\"rounded-full border border-dashed px-2 py-0.5 text-xs font-medium text-muted-foreground\">\n          Sample layout\n        </span>\n      </div>\n      <p className=\"mt-1 text-sm text-muted-foreground\">\n        Placeholder shapes showing where your charts and records will sit. No figures are shown because\n        there is nothing to report yet.\n      </p>\n\n      <div className=\"relative mt-4 overflow-hidden rounded-lg border border-dashed p-4\">\n        <div aria-hidden=\"true\" className=\"flex flex-col gap-4\" data-slot=\"preview-canvas\">\n          <div className=\"grid grid-cols-3 gap-3\">\n            {[\"a\", \"b\", \"c\"].map(key => (\n              <div className=\"flex flex-col gap-2 rounded-md border p-3\" key={key}>\n                <span className=\"h-2 w-2/3 rounded-full bg-muted\" />\n                <span className=\"h-4 w-1/2 rounded-full bg-muted-foreground/20\" />\n              </div>\n            ))}\n          </div>\n\n          <div className=\"flex h-24 items-end gap-1.5 rounded-md border p-3\">\n            {PREVIEW_BARS.map((height, index) => (\n              <span\n                className=\"min-w-0 flex-1 rounded-sm bg-muted-foreground/20 motion-safe:animate-pulse\"\n                key={index}\n                style={{ animationDelay: `${index * 90}ms`, height: `${height}%` }}\n              />\n            ))}\n          </div>\n\n          <div className=\"flex flex-col gap-2 rounded-md border p-3\">\n            {PREVIEW_ROWS.map((width, index) => (\n              <span className=\"flex items-center gap-2\" key={index}>\n                <span className=\"size-4 shrink-0 rounded-full bg-muted\" />\n                <span className=\"h-2 rounded-full bg-muted\" style={{ width: `${width}%` }} />\n              </span>\n            ))}\n          </div>\n        </div>\n\n        <span className=\"pointer-events-none absolute inset-0 flex items-center justify-center\">\n          <span className=\"rounded-full border bg-background/90 px-3 py-1 text-xs font-medium text-muted-foreground\">\n            Sample — not your data\n          </span>\n        </span>\n      </div>\n    </section>\n  )\n}\n\n/**\n * The first screen of a brand-new account: a welcome, a setup checklist whose\n * progress is derived from real task state (skipping never counts as done), a\n * labelled skeleton of the filled dashboard, and an optional sample-data entry\n * point behind a confirmation.\n *\n * Once nothing is left to do the block renders nothing and calls `onComplete`\n * exactly once, so it hands the space back instead of squatting on the home\n * page forever.\n */\nexport const EmptyDashboard = React.forwardRef<HTMLElement, EmptyDashboardProps>(function EmptyDashboard(\n  {\n    tasks,\n    title = \"Welcome — your dashboard is ready to fill\",\n    description = \"Three short steps and this page starts showing your own numbers. You can do them in any order.\",\n    checklistTitle = \"Set up your workspace\",\n    onTaskStatusChange,\n    onComplete,\n    onLoadSampleData,\n    sampleDataReversible = true,\n    showPreview = true,\n    className,\n    ...props\n  },\n  ref,\n) {\n  const uid = React.useId()\n  const titleId = `${uid}-title`\n  const checklistId = `${uid}-checklist`\n  const previewId = `${uid}-preview`\n  const confirmId = `${uid}-confirm`\n\n  const [samplePhase, setSamplePhase] = React.useState<SamplePhase>(\"idle\")\n  const [announcement, setAnnouncement] = React.useState(\"\")\n\n  const triggerRef = React.useRef<HTMLButtonElement>(null)\n  const cancelRef = React.useRef<HTMLButtonElement>(null)\n  const loadedRef = React.useRef<HTMLParagraphElement>(null)\n  const sampleFiredRef = React.useRef(false)\n  const phaseSeenRef = React.useRef<SamplePhase | null>(null)\n  const announcedRef = React.useRef<string | null>(null)\n  const completeFiredRef = React.useRef(false)\n\n  const onCompleteRef = React.useRef(onComplete)\n  const onLoadSampleDataRef = React.useRef(onLoadSampleData)\n  React.useEffect(() => {\n    onCompleteRef.current = onComplete\n    onLoadSampleDataRef.current = onLoadSampleData\n  })\n\n  // Duplicate ids would collide on React keys and make Skip toggle the wrong\n  // row. First occurrence wins.\n  const rows = React.useMemo(() => {\n    const seen = new Set<string>()\n    return tasks.filter(task => {\n      if (seen.has(task.id)) return false\n      seen.add(task.id)\n      return true\n    })\n  }, [tasks])\n\n  const total = rows.length\n  const completed = rows.filter(task => task.status === \"done\").length\n  const skipped = rows.filter(task => task.status === \"skipped\").length\n  const remaining = total - completed - skipped\n  const allResolved = total > 0 && remaining === 0\n\n  // The bar only ever fills with *done* work; skipped steps get their own muted\n  // segment so the meter can never imply an activation that did not happen.\n  const donePercent = total > 0 ? (completed / total) * 100 : 0\n  const skippedPercent = total > 0 ? (skipped / total) * 100 : 0\n\n  const counterText =\n    total === 0\n      ? \"\"\n      : [`${completed} of ${total} done`, skipped > 0 ? `${skipped} skipped` : null, `${remaining} left`]\n          .filter(Boolean)\n          .join(\" · \")\n\n  React.useEffect(() => {\n    // Nothing left to announce once the block has handed the space back.\n    if (!counterText || allResolved) return\n    // First paint is the baseline, not news.\n    if (announcedRef.current === null) {\n      announcedRef.current = counterText\n      return\n    }\n    if (announcedRef.current === counterText) return\n    const timer = window.setTimeout(() => {\n      announcedRef.current = counterText\n      setAnnouncement(counterText)\n    }, ANNOUNCE_DELAY)\n    return () => window.clearTimeout(timer)\n  }, [allResolved, counterText])\n\n  React.useEffect(() => {\n    if (!allResolved) {\n      completeFiredRef.current = false\n      return\n    }\n    if (completeFiredRef.current) return\n    completeFiredRef.current = true\n    onCompleteRef.current?.({ completed, skipped, total })\n  }, [allResolved, completed, skipped, total])\n\n  // Focus has to follow the sample-data step: its trigger unmounts when the\n  // confirmation opens, and the confirmation unmounts when it resolves.\n  React.useEffect(() => {\n    if (phaseSeenRef.current === null) {\n      phaseSeenRef.current = samplePhase\n      return\n    }\n    if (phaseSeenRef.current === samplePhase) return\n    phaseSeenRef.current = samplePhase\n    const node =\n      samplePhase === \"confirming\"\n        ? cancelRef.current\n        : samplePhase === \"loaded\"\n          ? loadedRef.current\n          : triggerRef.current\n    node?.focus()\n  }, [samplePhase])\n\n  const confirmSampleData = () => {\n    // Writing sample records twice would double the mess it makes; the guard is\n    // a ref so even same-tick repeat clicks cannot get through.\n    if (sampleFiredRef.current) return\n    sampleFiredRef.current = true\n    setSamplePhase(\"loaded\")\n    onLoadSampleDataRef.current?.()\n  }\n\n  if (allResolved) return null\n\n  return (\n    <section\n      aria-labelledby={titleId}\n      className={cn(\"flex w-full min-w-0 flex-col gap-6\", className)}\n      ref={ref}\n      {...props}\n    >\n      <header className=\"flex flex-col gap-2\">\n        <h2 className=\"text-xl font-semibold wrap-anywhere sm:text-2xl\" id={titleId}>\n          {title}\n        </h2>\n        <p className=\"max-w-2xl text-sm text-muted-foreground\">{description}</p>\n      </header>\n\n      <div\n        className={cn(\n          // items-start: the preview hugs its own content instead of stretching to\n          // the checklist's height, which would leave a tall empty dashed box that\n          // reads as \"content failed to load\".\n          \"grid min-w-0 items-start gap-6\",\n          showPreview && \"lg:grid-cols-[minmax(0,1fr)_minmax(0,0.9fr)]\",\n        )}\n      >\n        <div className=\"flex min-w-0 flex-col rounded-xl border bg-card p-5 text-card-foreground\">\n          <h3 className=\"text-sm font-semibold\" id={checklistId}>\n            {checklistTitle}\n          </h3>\n\n          {total > 0 && (\n            <div className=\"mt-3 flex flex-col gap-1.5\">\n              <div\n                aria-label={`${checklistTitle} progress`}\n                aria-valuemax={total}\n                aria-valuemin={0}\n                aria-valuenow={completed}\n                aria-valuetext={counterText}\n                className=\"flex h-1.5 w-full overflow-hidden rounded-full bg-muted\"\n                data-slot=\"progress\"\n                role=\"progressbar\"\n              >\n                <span\n                  className=\"h-full bg-primary transition-[width] duration-500 ease-out motion-reduce:transition-none\"\n                  data-slot=\"progress-done\"\n                  style={{ width: `${donePercent}%` }}\n                />\n                <span\n                  className=\"h-full bg-muted-foreground/30 transition-[width] duration-500 ease-out motion-reduce:transition-none\"\n                  data-slot=\"progress-skipped\"\n                  style={{ width: `${skippedPercent}%` }}\n                />\n              </div>\n              <p className=\"text-xs tabular-nums text-muted-foreground\" data-slot=\"progress-label\">\n                {counterText}\n              </p>\n            </div>\n          )}\n\n          {total === 0 ? (\n            <p className=\"mt-4 text-sm text-muted-foreground\">No setup steps are configured for this workspace.</p>\n          ) : (\n            /* Tailwind's preflight strips list-style, and with it the list\n               semantics some screen readers infer — restored explicitly so the\n               steps are still announced as a counted list. */\n            <ol className=\"mt-2 flex flex-col\" data-slot=\"task-list\" role=\"list\">\n              {rows.map((task, index) => {\n                const resolved = task.status !== \"todo\"\n                const canSkip = Boolean(onTaskStatusChange) && task.skippable !== false\n\n                return (\n                  <li className=\"border-t py-4 first:border-t-0\" data-slot=\"task\" data-status={task.status} key={task.id}>\n                    <div className=\"flex min-w-0 items-start gap-3\">\n                      <StatusMarker index={index} status={task.status} />\n\n                      <div className=\"flex min-w-0 flex-1 flex-col gap-1\">\n                        <div className=\"flex min-w-0 flex-wrap items-center gap-2\">\n                          <span\n                            className={cn(\n                              \"min-w-0 text-sm font-medium wrap-anywhere\",\n                              task.status === \"done\" && \"text-muted-foreground line-through\",\n                              task.status === \"skipped\" && \"text-muted-foreground\",\n                            )}\n                          >\n                            {task.title}\n                          </span>\n                          {task.status === \"skipped\" && (\n                            <span className=\"rounded-full border px-1.5 py-px text-[0.65rem] font-medium text-muted-foreground\">\n                              Skipped\n                            </span>\n                          )}\n                          {task.status === \"todo\" && task.estimateLabel && (\n                            <span className=\"text-xs text-muted-foreground\">{task.estimateLabel}</span>\n                          )}\n                        </div>\n\n                        {task.description && !resolved && (\n                          <p className=\"text-sm text-muted-foreground wrap-anywhere\">{task.description}</p>\n                        )}\n\n                        {!resolved && (\n                          <div className=\"mt-2 flex flex-wrap items-center gap-2\">\n                            <TaskAction action={task.action} />\n                            {canSkip && (\n                              <button\n                                className={QUIET_BUTTON}\n                                onClick={() => onTaskStatusChange?.(task.id, \"skipped\")}\n                                type=\"button\"\n                              >\n                                Skip for now\n                                <span className=\"sr-only\"> — {task.title}</span>\n                              </button>\n                            )}\n                          </div>\n                        )}\n\n                        {task.status === \"skipped\" && onTaskStatusChange && (\n                          <div className=\"mt-2\">\n                            <button\n                              className={QUIET_BUTTON}\n                              onClick={() => onTaskStatusChange(task.id, \"todo\")}\n                              type=\"button\"\n                            >\n                              Undo skip\n                              <span className=\"sr-only\"> — {task.title}</span>\n                            </button>\n                          </div>\n                        )}\n                      </div>\n                    </div>\n                  </li>\n                )\n              })}\n            </ol>\n          )}\n\n          {onLoadSampleData && (\n            <div className=\"mt-auto border-t pt-4\" data-slot=\"sample-data\">\n              {samplePhase === \"idle\" && (\n                <div className=\"flex flex-wrap items-center justify-between gap-3\">\n                  <p className=\"min-w-0 text-sm text-muted-foreground\">\n                    Want to look around first? Fill this workspace with example records.\n                  </p>\n                  <button className={GHOST_BUTTON} onClick={() => setSamplePhase(\"confirming\")} ref={triggerRef} type=\"button\">\n                    <Database aria-hidden=\"true\" className=\"size-4\" />\n                    Load sample data\n                  </button>\n                </div>\n              )}\n\n              {samplePhase === \"confirming\" && (\n                <div\n                  aria-labelledby={confirmId}\n                  className=\"rounded-lg border bg-muted/40 p-4\"\n                  data-slot=\"sample-confirm\"\n                  onKeyDown={event => {\n                    if (event.key !== \"Escape\") return\n                    event.stopPropagation()\n                    setSamplePhase(\"idle\")\n                  }}\n                  role=\"group\"\n                >\n                  <p className=\"text-sm font-medium\" id={confirmId}>\n                    Add sample data to this workspace?\n                  </p>\n                  <p className=\"mt-1 text-sm text-muted-foreground\">\n                    This writes example projects and records into the workspace, and everyone with access will\n                    see them.{\" \"}\n                    {sampleDataReversible\n                      ? \"You can delete them later from workspace settings.\"\n                      : \"This cannot be undone.\"}\n                  </p>\n                  <div className=\"mt-3 flex flex-wrap gap-2\">\n                    <button className={PRIMARY_BUTTON} onClick={confirmSampleData} type=\"button\">\n                      Yes, add sample data\n                    </button>\n                    <button className={GHOST_BUTTON} onClick={() => setSamplePhase(\"idle\")} ref={cancelRef} type=\"button\">\n                      Cancel\n                    </button>\n                  </div>\n                </div>\n              )}\n\n              {samplePhase === \"loaded\" && (\n                <p\n                  className={cn(\"rounded-lg border bg-muted/40 p-4 text-sm\", FOCUS_RING)}\n                  data-slot=\"sample-loaded\"\n                  ref={loadedRef}\n                  role=\"status\"\n                  tabIndex={-1}\n                >\n                  Sample data added.{\" \"}\n                  {sampleDataReversible\n                    ? \"Delete it from workspace settings whenever you are done exploring.\"\n                    : \"It cannot be removed automatically — ask support if you need it gone.\"}\n                </p>\n              )}\n            </div>\n          )}\n        </div>\n\n        {showPreview && <DashboardPreview headingId={previewId} />}\n      </div>\n\n      <span aria-live=\"polite\" className=\"sr-only\" role=\"status\">\n        {announcement}\n      </span>\n    </section>\n  )\n})\n\nEmptyDashboard.displayName = \"EmptyDashboard\"\n\nexport default EmptyDashboard\n",
      "type": "registry:block"
    }
  ],
  "type": "registry:block"
}