{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "runbook-1",
  "title": "Runbook 1: guided runbook",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A runbook to follow under pressure: ordered steps with a tick each, the next step marked, commands to copy but never run for you, cautions read before the step, minutes left, and Finish only when every step is done.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/runbook-1/runbook-1.tsx",
      "content": "// Ballmac UI: Runbook 1. https://ui.ballmac.com/blocks/runbook-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { CircleCheck, Clipboard, ClipboardCheck, Clock, Loader2, TriangleAlert } from \"lucide-react\"\n\nimport { Button } from \"@/components/ballmac/button\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { cn } from \"@/lib/utils\"\n\ntype Runbook1Step = {\n  id: string\n  title: string\n  /** What to do and why, in a sentence or two. */\n  body: string\n  /** A command to run. Shown as code with a copy button; it never runs by itself. */\n  command?: string\n  /** A warning to read before doing the step. */\n  caution?: string\n  /** About how many minutes the step takes. */\n  minutes?: number\n}\n\ntype Runbook1Data = {\n  title: string\n  /** What it is for, such as \"The primary database is out of connections\". */\n  when: string\n  steps: Runbook1Step[]\n}\n\ntype Runbook1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    runbook?: Runbook1Data | null\n    /** Step ids that are already done. */\n    defaultDone?: string[]\n    /** Called when a step is ticked or unticked. Reject and the tick goes back with the message. */\n    onStepChange?: (stepId: string, done: boolean) => void | Promise<void>\n    /** Called when every step is done and the person finishes the runbook. */\n    onComplete?: () => void | Promise<void>\n  }\n\nconst sample: Runbook1Data = {\n  title: \"Database is out of connections\",\n  when: \"Use this when the primary database refuses new connections and the API returns 503.\",\n  steps: [\n    { id: \"s1\", title: \"Confirm it is the connection limit\", body: \"Check that the count is at the limit before changing anything. If it is not, this is the wrong runbook.\", command: \"psql -c \\\"select count(*), max_conn from pg_stat_activity, (select setting::int max_conn from pg_settings where name='max_connections') s group by 2;\\\"\", minutes: 2 },\n    { id: \"s2\", title: \"Find what is holding connections\", body: \"Look for one client with a very large share, or for idle-in-transaction sessions that never finish.\", command: \"psql -c \\\"select client_addr, state, count(*) from pg_stat_activity group by 1,2 order by 3 desc limit 10;\\\"\", minutes: 3 },\n    { id: \"s3\", title: \"End idle-in-transaction sessions\", body: \"These can be ended safely. Their open transactions are rolled back.\", caution: \"This rolls back work that was never committed. Do not run it while a migration is in progress.\", command: \"psql -c \\\"select pg_terminate_backend(pid) from pg_stat_activity where state='idle in transaction' and now()-state_change > interval '5 minutes';\\\"\", minutes: 2 },\n    { id: \"s4\", title: \"Restart the client that leaked connections\", body: \"If one service holds most of them, restart it so it opens a fresh pool. Start with one instance and watch the count.\", minutes: 5 },\n    { id: \"s5\", title: \"Post an update and watch for ten minutes\", body: \"Tell the channel what you did, and keep an eye on the connection count and the API error rate before closing the incident.\", minutes: 10 },\n  ],\n}\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"grid gap-3\">\n      <Skeleton className=\"h-20 rounded-xl\" />\n      {[0, 1, 2, 3].map((i) => (\n        <Skeleton key={i} className=\"h-28 rounded-xl\" />\n      ))}\n    </div>\n  )\n}\n\n/**\n * A runbook someone follows under pressure: the steps in order with a tick for each, the next step marked, commands to copy (never run for you), cautions read\n * out before the step, and a count of the minutes left. Finishing is only possible when every step is ticked.\n */\nfunction Runbook1({ runbook = sample, defaultDone = [], onStepChange, onComplete, state = \"ready\", onRetry, emptyState, errorState, className, ...props }: Runbook1Props) {\n  const id = React.useId()\n  const [done, setDone] = React.useState<string[]>(defaultDone)\n  const [copied, setCopied] = React.useState<string | null>(null)\n  const [error, setError] = React.useState<{ id: string; text: string } | null>(null)\n  const [finished, setFinished] = React.useState<{ ok: boolean; text: string } | null>(null)\n  const [busy, setBusy] = React.useState(false)\n  const view = state === \"ready\" && !runbook ? \"empty\" : state\n  const steps = runbook?.steps ?? []\n  const next = steps.find((s) => !done.includes(s.id))\n  const left = steps.filter((s) => !done.includes(s.id)).reduce((n, s) => n + (s.minutes ?? 0), 0)\n\n  async function tick(s: Runbook1Step, on: boolean) {\n    setError(null)\n    setFinished(null)\n    setDone((d) => (on ? [...d, s.id] : d.filter((x) => x !== s.id)))\n    try {\n      await onStepChange?.(s.id, on)\n    } catch (e) {\n      setDone((d) => (on ? d.filter((x) => x !== s.id) : [...d, s.id]))\n      setError({ id: s.id, text: e instanceof Error ? e.message : \"That did not save, so the step is as it was.\" })\n    }\n  }\n  async function copy(s: Runbook1Step) {\n    try {\n      await navigator.clipboard.writeText(s.command ?? \"\")\n      setCopied(s.id)\n      window.setTimeout(() => setCopied((c) => (c === s.id ? null : c)), 2000)\n    } catch {\n      setError({ id: s.id, text: \"The command could not be copied. Select it and copy it by hand.\" })\n    }\n  }\n  async function complete() {\n    setBusy(true)\n    setFinished(null)\n    try {\n      await onComplete?.()\n      setFinished({ ok: true, text: \"Runbook finished. Remember to post a final update.\" })\n    } catch (e) {\n      setFinished({ ok: false, text: e instanceof Error ? e.message : \"The runbook could not be finished.\" })\n    } finally {\n      setBusy(false)\n    }\n  }\n\n  return (\n    <section data-slot=\"runbook-1\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-3xl px-4 py-10 sm:px-6\", className)} {...frameProps(props)}>\n      <StateView state={view} onRetry={onRetry} label=\"Loading the runbook\" loading={<Skel />} empty={{ title: \"No runbook for this\", description: \"Write a runbook for this alert and it will open here with its steps.\", ...emptyState }} error={{ title: \"The runbook could not be loaded\", description: \"Nothing was changed. Check your connection and try again.\", ...errorState }}>\n        {runbook && (\n          <>\n            <header className=\"mb-6\">\n              <h1 id={`${id}-title`} className=\"text-3xl font-semibold tracking-[-0.035em] text-balance\">{runbook.title}</h1>\n              <p className=\"text-muted-foreground mt-2 max-w-xl text-pretty\">{runbook.when}</p>\n            </header>\n            <div className=\"bg-card mb-5 grid gap-2.5 rounded-xl border p-4\">\n              <div className=\"flex flex-wrap items-baseline justify-between gap-2 text-sm\">\n                <p className=\"font-medium tabular-nums\">{done.length} of {steps.length} steps done</p>\n                <p className=\"text-muted-foreground flex items-center gap-1.5 text-xs tabular-nums\"><Clock aria-hidden=\"true\" className=\"size-3.5\" /> {left > 0 ? `About ${left} min left` : \"No time left on the steps\"}</p>\n              </div>\n              <div role=\"meter\" aria-label=\"Steps done\" aria-valuemin={0} aria-valuemax={steps.length} aria-valuenow={done.length} aria-valuetext={`${done.length} of ${steps.length} steps done`} className=\"bg-muted h-2 overflow-hidden rounded-full\">\n                <div className=\"bg-foreground h-full rounded-full transition-[width] duration-300 motion-reduce:transition-none\" style={{ width: `${steps.length ? (done.length / steps.length) * 100 : 0}%` }} />\n              </div>\n            </div>\n\n            <ol className=\"grid gap-3\">\n              {steps.map((s, i) => {\n                const isDone = done.includes(s.id)\n                const isNext = next?.id === s.id\n                return (\n                  <li key={s.id} data-done={isDone || undefined} aria-current={isNext ? \"step\" : undefined} className={cn(\"bg-card rounded-xl border p-4 sm:p-5\", isNext && \"border-foreground shadow-[0_0_0_1px_var(--foreground)]\", isDone && \"bg-muted/30\")}>\n                    <div className=\"flex items-start gap-3\">\n                      <input id={`${id}-${s.id}`} type=\"checkbox\" checked={isDone} onChange={(e) => void tick(s, e.target.checked)} className=\"accent-foreground mt-1 size-5 shrink-0 cursor-pointer\" />\n                      <div className=\"min-w-0 flex-1\">\n                        <label htmlFor={`${id}-${s.id}`} className=\"flex cursor-pointer flex-wrap items-baseline gap-x-2 gap-y-0.5\">\n                          <span className=\"text-muted-foreground font-mono text-xs tabular-nums\">{i + 1}</span>\n                          <span className={cn(\"font-medium\", isDone && \"text-muted-foreground line-through\")}>{s.title}</span>\n                          {isNext && <span className=\"bg-foreground text-background rounded-full px-2 py-px text-[11px] font-medium\">Next</span>}\n                          {s.minutes !== undefined && <span className=\"text-muted-foreground text-xs\">· about {s.minutes} min</span>}\n                        </label>\n                        <p className=\"text-muted-foreground mt-1.5 text-sm leading-relaxed text-pretty\">{s.body}</p>\n                        {s.caution && (\n                          <p className=\"mt-3 flex gap-2 rounded-lg border border-chart-3/60 bg-chart-3/10 px-3 py-2 text-sm\">\n                            <TriangleAlert aria-hidden=\"true\" className=\"mt-0.5 size-4 shrink-0\" />\n                            <span><strong>Careful:</strong> {s.caution}</span>\n                          </p>\n                        )}\n                        {s.command && (\n                          <div className=\"mt-3 overflow-hidden rounded-lg border\">\n                            <div className=\"bg-muted/50 flex items-center justify-between gap-2 border-b px-3 py-1.5\">\n                              <span className=\"text-muted-foreground text-xs\">Command</span>\n                              <button type=\"button\" onClick={() => void copy(s)} aria-label={`Copy the command for step ${i + 1}`} className=\"focus-visible:ring-ring/50 hover:bg-accent text-muted-foreground hover:text-foreground flex h-7 items-center gap-1.5 rounded-md px-2 text-xs font-medium outline-none transition-colors focus-visible:ring-[3px] motion-reduce:transition-none\">\n                                {copied === s.id ? <ClipboardCheck aria-hidden=\"true\" className=\"size-3.5\" /> : <Clipboard aria-hidden=\"true\" className=\"size-3.5\" />} {copied === s.id ? \"Copied\" : \"Copy\"}\n                              </button>\n                            </div>\n                            <pre dir=\"ltr\" tabIndex={0} aria-label={`Command for step ${i + 1}`} className=\"focus-visible:ring-ring/50 overflow-x-auto px-3 py-2.5 font-mono text-xs leading-relaxed outline-none focus-visible:ring-[3px] focus-visible:ring-inset\">{s.command}</pre>\n                          </div>\n                        )}\n                        {error?.id === s.id && <p role=\"alert\" className=\"text-destructive mt-2 text-sm font-medium\">{error.text}</p>}\n                      </div>\n                    </div>\n                  </li>\n                )\n              })}\n            </ol>\n\n            <div className=\"bg-background/90 sticky bottom-3 mt-6 flex flex-wrap items-center justify-between gap-3 rounded-2xl border p-3 shadow-lg backdrop-blur\">\n              <p aria-live=\"polite\" role={finished && !finished.ok ? \"alert\" : undefined} className={cn(\"flex items-center gap-2 text-sm\", finished && !finished.ok && \"text-destructive font-medium\")}>\n                {finished?.ok && <CircleCheck aria-hidden=\"true\" className=\"size-4\" />}\n                {finished ? finished.text : next ? <>Next: <strong>{next.title}</strong></> : \"Every step is done.\"}\n              </p>\n              <Button disabled={!!next || busy || finished?.ok === true} onClick={() => void complete()}>\n                {busy && <Loader2 aria-hidden=\"true\" className=\"animate-spin\" />} Finish runbook\n              </Button>\n            </div>\n          </>\n        )}\n      </StateView>\n    </section>\n  )\n}\n\nexport { Runbook1, type Runbook1Data, type Runbook1Props, type Runbook1Step }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/runbook-1/runbook-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "runbook",
      "playbook",
      "incident",
      "sre",
      "operations",
      "checklist",
      "procedure",
      "on-call"
    ],
    "version": "1.0.0",
    "updated": "2026-10-24",
    "tests": true,
    "ai": {
      "summary": "A guided runbook. <Runbook1 runbook={{ title, when, steps: [{ id, title, body, command?, caution?, minutes? }] }} defaultDone={['s1']} onStepChange={(stepId, done) => Promise} onComplete={() => Promise} />. The first step not done is marked Next (aria-current='step'). Commands are shown as code with a Copy button and are never executed. Finish is off until every step is ticked.",
      "whenToUse": [
        "The page an on-call engineer follows for a known problem",
        "Any procedure where order matters and each step must be confirmed"
      ],
      "whenNotToUse": [
        "An onboarding checklist for users; use onboarding-1",
        "A deploy's stages; use pipeline-1"
      ],
      "composesWith": [
        "incident-1",
        "alerts-1",
        "oncall-1",
        "matrix-1"
      ],
      "a11y": [
        {
          "keys": "Steps",
          "action": "An ordered list; the next step has aria-current='step' and is also marked 'Next' in words"
        },
        {
          "keys": "Ticks",
          "action": "Real checkboxes labelled with the step; a failed save puts the tick back and is an alert"
        },
        {
          "keys": "Commands",
          "action": "Left to right, in a focusable labelled region with a Copy button; nothing runs automatically"
        },
        {
          "keys": "Caution",
          "action": "Written as 'Careful:' with an icon before the step"
        },
        {
          "keys": "Progress",
          "action": "A meter reading '2 of 5 steps done' and the minutes left in text"
        }
      ],
      "customization": [
        "steps with commands, cautions and minutes",
        "defaultDone",
        "onStepChange and onComplete"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "runbook-1-loading",
        "trigger": "state=\"loading\"",
        "note": "Skeleton for the progress and the steps under the page, announced as loading."
      },
      {
        "id": "empty",
        "title": "No runbook for this",
        "example": "runbook-1-empty",
        "trigger": "state=\"empty\"",
        "note": "Says there is no runbook for this; pass runbook={null} and emptyState sets the copy."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "runbook-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert saying nothing was changed, with Try again (onRetry). A tick that cannot be saved goes back and says why."
      }
    ],
    "examples": [
      "runbook-1-demo",
      "runbook-1-done",
      "runbook-1-loading",
      "runbook-1-empty",
      "runbook-1-error"
    ],
    "url": "https://ui.ballmac.com/components/runbook-1"
  },
  "categories": [
    "blocks",
    "runbook"
  ],
  "type": "registry:block"
}