{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "api-playground",
  "title": "API Playground",
  "description": "A try-it console that generates parameter, header and body editors from an operation definition, sends the request through an injected fetcher, and reports status, timing, size and headers with a pretty/raw toggle.",
  "dependencies": [
    "lucide-react",
    "zod"
  ],
  "registryDependencies": [
    "badge",
    "button",
    "dropdown-menu",
    "https://ui.zyeon.ai/r/use-copy-to-clipboard.json",
    "input",
    "label",
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/blocks/api-playground.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  Ban,\n  Check,\n  ChevronDown,\n  CircleDashed,\n  Copy,\n  Eye,\n  EyeOff,\n  LoaderCircle,\n  OctagonAlert,\n  Play,\n  RotateCw,\n  Terminal,\n  X,\n} from \"lucide-react\"\nimport { Badge } from \"@/components/ui/badge\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuRadioGroup,\n  DropdownMenuRadioItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\"\nimport { Input } from \"@/components/ui/input\"\nimport { Label } from \"@/components/ui/label\"\nimport { useCopyToClipboard } from \"@/hooks/use-copy-to-clipboard\"\nimport { cn } from \"@/lib/utils\"\nimport type {\n  ApiOperation,\n  ApiParameter,\n  ApiPlaygroundData,\n  ApiRequest,\n  ApiResponse,\n  HttpMethod,\n  ParameterLocation,\n} from \"./api-playground.contract\"\n\n/** Rows the body editor opens with, and the range `bodyRows` is clamped into. */\nconst DEFAULT_BODY_ROWS = 8\nconst MIN_BODY_ROWS = 3\nconst MAX_BODY_ROWS = 40\n/** Bullets drawn for a masked value — a FIXED run, so the length of the secret leaks nothing either. */\nconst MASK_LENGTH = 10\n\n/** Every method reads as a word first; the tint is only a second signal. */\nconst METHOD_TONE: Record<HttpMethod, string> = {\n  GET: \"bg-primary/10 text-primary\",\n  POST: \"bg-primary text-primary-foreground\",\n  PUT: \"bg-muted text-foreground\",\n  PATCH: \"bg-muted text-foreground\",\n  DELETE: \"bg-destructive/10 text-destructive\",\n  HEAD: \"bg-muted text-muted-foreground\",\n  OPTIONS: \"bg-muted text-muted-foreground\",\n}\n\nconst GROUPS: { in: ParameterLocation; label: string }[] = [\n  { in: \"path\", label: \"Path parameters\" },\n  { in: \"query\", label: \"Query parameters\" },\n  { in: \"header\", label: \"Headers\" },\n]\n\n/* ------------------------------------------------------------------ maths */\n\n/** Values are keyed by location AND name: `limit` may legally be both a query and a header. */\nexport function parameterKey(parameter: Pick<ApiParameter, \"in\" | \"name\">): string {\n  return `${parameter.in}:${parameter.name}`\n}\n\n/** ms as reported by the fetcher. Sub-millisecond reads \"<1 ms\" rather than \"0 ms\". */\nexport function formatDuration(ms: number): string {\n  if (!Number.isFinite(ms) || ms < 0) return \"—\"\n  if (ms < 1) return \"<1 ms\"\n  if (ms < 1000) return `${Math.round(ms)} ms`\n  return `${(ms / 1000).toFixed(2)} s`\n}\n\n/**\n * UTF-8 byte length, not `string.length`: one emoji is two code units and four\n * bytes, and a response pane that reports code units is lying about the wire.\n */\nexport function byteLength(text: string): number {\n  if (typeof TextEncoder !== \"undefined\") return new TextEncoder().encode(text).length\n  let bytes = 0\n  for (const character of text) {\n    const code = character.codePointAt(0) ?? 0\n    bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4\n  }\n  return bytes\n}\n\nexport function formatBytes(bytes: number): string {\n  if (!Number.isFinite(bytes) || bytes < 0) return \"—\"\n  if (bytes < 1024) return `${Math.round(bytes)} B`\n  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`\n  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`\n}\n\nexport type ResponseClass =\n  | \"informational\"\n  | \"success\"\n  | \"redirect\"\n  | \"client_error\"\n  | \"server_error\"\n  | \"unknown\"\n\n/** Ordered low to high so a code outside 100-599 falls into \"unknown\" instead of the last branch. */\nexport function responseClass(status: number): ResponseClass {\n  if (!Number.isFinite(status) || status < 100 || status >= 600) return \"unknown\"\n  if (status < 200) return \"informational\"\n  if (status < 300) return \"success\"\n  if (status < 400) return \"redirect\"\n  if (status < 500) return \"client_error\"\n  return \"server_error\"\n}\n\nconst CLASS_WORD: Record<ResponseClass, string> = {\n  informational: \"Informational\",\n  success: \"Success\",\n  redirect: \"Redirect\",\n  client_error: \"Client error\",\n  server_error: \"Server error\",\n  unknown: \"Unknown status\",\n}\n\nconst CLASS_TONE: Record<ResponseClass, string> = {\n  informational: \"bg-muted text-muted-foreground\",\n  success: \"bg-primary/10 text-primary\",\n  redirect: \"bg-muted text-foreground\",\n  client_error: \"bg-destructive/10 text-destructive\",\n  server_error: \"bg-destructive text-background\",\n  unknown: \"bg-muted text-muted-foreground\",\n}\n\n/** Only `application/json` and its `+json` relatives get parsed and pretty-printed. */\nfunction isJsonType(contentType: string): boolean {\n  return /\\bjson\\b/i.test(contentType)\n}\n\n/** `null` means \"this text is not JSON\" — the caller locks the toggle to raw instead of guessing. */\nexport function prettyPrintJson(text: string): string | null {\n  if (text.trim() === \"\") return null\n  try {\n    return JSON.stringify(JSON.parse(text) as unknown, null, 2)\n  } catch {\n    return null\n  }\n}\n\nexport interface RequestIssues {\n  /** parameterKey -> the one sentence shown under that editor. */\n  fields: Record<string, string>\n  /** Body-level problem, or null. */\n  body: string | null\n  /** Path placeholders with no matching path parameter — nothing can fill them in. */\n  unmatchedPath: string[]\n}\n\nconst NO_ISSUES: RequestIssues = { fields: {}, body: null, unmatchedPath: [] }\n\nexport function hasIssues(issues: RequestIssues): boolean {\n  return (\n    issues.body !== null ||\n    issues.unmatchedPath.length > 0 ||\n    Object.keys(issues.fields).length > 0\n  )\n}\n\n/** \"3 fields\" / \"1 field\" — the neutral sentence shown before the first send attempt. */\nfunction pendingCount(issues: RequestIssues): string {\n  const count = Object.keys(issues.fields).length + (issues.body === null ? 0 : 1)\n  return `${count} field${count === 1 ? \"\" : \"s\"}`\n}\n\n/**\n * Everything that must be true before a request may leave. Empty optional values\n * are not errors — they are simply omitted from the URL and the headers.\n */\nexport function validateRequest(\n  operation: ApiOperation,\n  values: Record<string, string>,\n  bodyText: string,\n): RequestIssues {\n  const fields: Record<string, string> = {}\n\n  for (const parameter of operation.parameters) {\n    const key = parameterKey(parameter)\n    const value = (values[key] ?? \"\").trim()\n    if (value === \"\") {\n      if (parameter.required) fields[key] = `${parameter.name} is required.`\n      continue\n    }\n    if (parameter.kind === \"number\" && !Number.isFinite(Number(value))) {\n      fields[key] = `${parameter.name} must be a number.`\n    } else if (parameter.kind === \"boolean\" && value !== \"true\" && value !== \"false\") {\n      fields[key] = `${parameter.name} must be true or false.`\n    } else if (\n      parameter.kind === \"enum\" &&\n      parameter.options !== undefined &&\n      parameter.options.length > 0 &&\n      !parameter.options.includes(value)\n    ) {\n      fields[key] = `${parameter.name} must be one of: ${parameter.options.join(\", \")}.`\n    }\n  }\n\n  // A placeholder nobody declared can never be filled, so the URL would go out\n  // with a literal \"{id}\" in it. That is a blocking problem, not a warning.\n  const unmatchedPath = [...operation.path.matchAll(/\\{([^{}]+)\\}/g)]\n    .map(match => match[1])\n    .filter(name => !operation.parameters.some(p => p.in === \"path\" && p.name === name))\n\n  let body: string | null = null\n  if (operation.body !== null) {\n    const trimmed = bodyText.trim()\n    if (operation.body.required && trimmed === \"\") {\n      body = \"A request body is required.\"\n    } else if (trimmed !== \"\" && isJsonType(operation.body.contentType) && prettyPrintJson(bodyText) === null) {\n      body = \"The body is not valid JSON.\"\n    }\n  }\n\n  return { body, fields, unmatchedPath }\n}\n\n/** Escape a fragment that will sit inside a double-quoted shell word. */\nfunction escapeDoubleQuoted(value: string): string {\n  return value.replace(/([\"\\\\$`])/g, \"\\\\$1\")\n}\n\n/** Escape a fragment that will sit inside a single-quoted shell word (POSIX has no escape inside). */\nfunction escapeSingleQuoted(value: string): string {\n  return value.replace(/'/g, `'\\\\''`)\n}\n\n/**\n * A shell variable name derived from the parameter: \"X-Api-Key\" -> \"$X_API_KEY\".\n * The snippet stays runnable — export the variable and paste — while the secret\n * itself never leaves the browser.\n */\nexport function shellVariableName(name: string): string {\n  const cleaned = name.replace(/[^a-zA-Z0-9]+/g, \"_\").replace(/^_+|_+$/g, \"\").toUpperCase()\n  if (cleaned === \"\") return \"SECRET\"\n  return /^[0-9]/.test(cleaned) ? `V_${cleaned}` : cleaned\n}\n\n/**\n * The URL, built twice from one function so the two can never drift: \"wire\" is\n * what the fetcher gets (real values, raw), \"snippet\" is what the clipboard gets\n * (secrets replaced by shell variables, every other fragment escaped for a\n * double-quoted word).\n */\nfunction composeUrl(\n  operation: ApiOperation,\n  values: Record<string, string>,\n  target: \"wire\" | \"snippet\",\n): string {\n  const read = (parameter: ApiParameter) => (values[parameterKey(parameter)] ?? \"\").trim()\n  const literal = (text: string) => (target === \"snippet\" ? escapeDoubleQuoted(text) : text)\n  const encode = (parameter: ApiParameter, value: string) => {\n    if (target === \"snippet\" && parameter.secret === true) return `$${shellVariableName(parameter.name)}`\n    const encoded = encodeURIComponent(value)\n    return target === \"snippet\" ? escapeDoubleQuoted(encoded) : encoded\n  }\n\n  const pathParams = operation.parameters.filter(parameter => parameter.in === \"path\")\n  const filledPath = operation.path\n    .split(/(\\{[^{}]+\\})/g)\n    .map(part => {\n      const placeholder = /^\\{([^{}]+)\\}$/.exec(part)\n      if (placeholder === null) return literal(part)\n      const match = pathParams.find(parameter => parameter.name === placeholder[1])\n      const value = match === undefined ? \"\" : read(match)\n      // An unfilled hole keeps its braces so the preview shows exactly what is\n      // still missing instead of collapsing into a silently wrong URL.\n      return match === undefined || value === \"\" ? literal(part) : encode(match, value)\n    })\n    .join(\"\")\n\n  const search = operation.parameters\n    .filter(parameter => parameter.in === \"query\" && read(parameter) !== \"\")\n    .map(parameter => `${literal(encodeURIComponent(parameter.name))}=${encode(parameter, read(parameter))}`)\n    .join(\"&\")\n\n  const origin = literal(operation.baseUrl.replace(/\\/+$/, \"\"))\n  const separator = filledPath.startsWith(\"/\") ? \"\" : \"/\"\n  return `${origin}${separator}${filledPath}${search === \"\" ? \"\" : `?${search}`}`\n}\n\n/** Every header parameter that actually carries something. Empty ones are omitted, not sent blank. */\nfunction composeHeaders(operation: ApiOperation, values: Record<string, string>): ApiParameter[] {\n  return operation.parameters.filter(\n    parameter => parameter.in === \"header\" && (values[parameterKey(parameter)] ?? \"\").trim() !== \"\",\n  )\n}\n\n/**\n * What goes on the wire. Values are trimmed at the edges (an HTTP token cannot\n * start with a space) but the body is sent byte for byte.\n */\nexport function buildRequest(\n  operation: ApiOperation,\n  values: Record<string, string>,\n  bodyText: string,\n): ApiRequest {\n  const headers: Record<string, string> = {}\n  for (const parameter of composeHeaders(operation, values)) {\n    headers[parameter.name] = (values[parameterKey(parameter)] ?? \"\").trim()\n  }\n\n  const body = operation.body === null ? null : bodyText\n  // Content-Type is added only when a body is going out, and never overrides one\n  // the definition already declared as a header parameter.\n  if (body !== null && !Object.keys(headers).some(name => name.toLowerCase() === \"content-type\")) {\n    headers[\"Content-Type\"] = operation.body?.contentType ?? \"application/json\"\n  }\n\n  return { body, headers, method: operation.method, url: composeUrl(operation, values, \"wire\") }\n}\n\n/** The copyable snippet. Secrets become `$VAR`; nothing else changes. */\nexport function buildCurl(\n  operation: ApiOperation,\n  values: Record<string, string>,\n  bodyText: string,\n): string {\n  // -X is always explicit: a -d payload must never silently promote the request.\n  const lines = [`curl -X ${operation.method} \"${composeUrl(operation, values, \"snippet\")}\"`]\n\n  const headers = composeHeaders(operation, values)\n  for (const parameter of headers) {\n    const value = (values[parameterKey(parameter)] ?? \"\").trim()\n    const printed =\n      parameter.secret === true\n        ? `$${shellVariableName(parameter.name)}`\n        : escapeDoubleQuoted(value)\n    lines.push(`-H \"${escapeDoubleQuoted(parameter.name)}: ${printed}\"`)\n  }\n\n  if (operation.body !== null) {\n    if (!headers.some(parameter => parameter.name.toLowerCase() === \"content-type\")) {\n      lines.push(`-H \"Content-Type: ${escapeDoubleQuoted(operation.body.contentType)}\"`)\n    }\n    if (bodyText.trim() !== \"\") lines.push(`-d '${escapeSingleQuoted(bodyText)}'`)\n  }\n\n  return lines.join(\" \\\\\\n  \")\n}\n\nfunction maskValue(value: string): string {\n  return value === \"\" ? \"\" : \"•\".repeat(MASK_LENGTH)\n}\n\nfunction clampInt(value: number, min: number, max: number): number {\n  if (!Number.isFinite(value)) return min\n  return Math.min(max, Math.max(min, Math.round(value)))\n}\n\n/* --------------------------------------------------------------- fragments */\n\nconst focusRing =\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 softButton = cn(\n  \"aria-disabled:cursor-not-allowed aria-disabled:opacity-60 aria-disabled:pointer-events-auto\",\n  \"aria-disabled:hover:bg-transparent aria-disabled:hover:text-inherit\",\n)\n\nfunction Panel({ children, className }: { children: React.ReactNode; className?: string }) {\n  return (\n    <div\n      className={cn(\n        \"flex flex-col items-center gap-2 rounded-xl border bg-card px-4 py-12 text-center\",\n        className,\n      )}\n    >\n      {children}\n    </div>\n  )\n}\n\n/** Placeholders are highlighted so an unfilled hole is visible, not inferred. */\nfunction PathText({ path, missing }: { path: string; missing: string[] }) {\n  return (\n    <>\n      {path.split(/(\\{[^{}]+\\})/g).map((part, index) => {\n        const placeholder = /^\\{([^{}]+)\\}$/.exec(part)\n        if (placeholder === null) return <React.Fragment key={`${part}-${index}`}>{part}</React.Fragment>\n        const unmatched = missing.includes(placeholder[1])\n        return (\n          <span\n            className={cn(\n              \"rounded-sm px-0.5\",\n              unmatched ? \"bg-destructive/10 text-destructive\" : \"bg-primary/10 text-primary\",\n            )}\n            key={`${part}-${index}`}\n          >\n            {part}\n          </span>\n        )\n      })}\n    </>\n  )\n}\n\ninterface CopyActionProps {\n  className?: string\n  label: string\n  text: string\n}\n\n/** Copy with an honest failure: a refused clipboard says so instead of faking a tick. */\nfunction CopyAction({ className, label, text }: CopyActionProps) {\n  const { copied, copy, error } = useCopyToClipboard()\n  return (\n    <span className={cn(\"inline-flex min-w-0 flex-col items-end gap-1\", className)}>\n      <Button\n        className={cn(softButton, focusRing)}\n        onClick={() => {\n          void copy(text)\n        }}\n        size=\"xs\"\n        type=\"button\"\n        variant=\"outline\"\n      >\n        {copied ? <Check aria-hidden=\"true\" /> : <Copy aria-hidden=\"true\" />}\n        {copied ? \"Copied\" : label}\n      </Button>\n      {error !== null && (\n        <span className=\"text-xs text-destructive wrap-anywhere\" role=\"status\">\n          Copy failed: {error.message}\n        </span>\n      )}\n    </span>\n  )\n}\n\ninterface ParameterFieldProps {\n  error: string | undefined\n  idBase: string\n  onChange: (key: string, value: string) => void\n  onReveal: (key: string) => void\n  parameter: ApiParameter\n  register: (key: string, node: HTMLElement | null) => void\n  revealed: boolean\n  showError: boolean\n  value: string\n}\n\nfunction ParameterField({\n  error,\n  idBase,\n  onChange,\n  onReveal,\n  parameter,\n  register,\n  revealed,\n  showError,\n  value,\n}: ParameterFieldProps) {\n  const key = parameterKey(parameter)\n  const controlId = `${idBase}-${key}`\n  const describeId = `${controlId}-desc`\n  const errorId = `${controlId}-error`\n  const invalid = showError && error !== undefined\n  const describedBy =\n    [parameter.description === undefined ? null : describeId, invalid ? errorId : null]\n      .filter((id): id is string => id !== null)\n      .join(\" \") || undefined\n\n  const choices =\n    parameter.kind === \"boolean\"\n      ? [\"true\", \"false\"]\n      : parameter.kind === \"enum\"\n        ? (parameter.options ?? [])\n        : []\n  // An enum whose options never arrived falls back to a text input: a menu with\n  // nothing in it cannot be opened, and that would strand the whole request.\n  const asMenu = choices.length > 0\n  const secret = parameter.secret === true\n  const shown = secret && !revealed ? maskValue(value) : value\n\n  return (\n    <div className=\"flex min-w-0 flex-col gap-1.5\">\n      <Label className=\"flex-wrap gap-1.5 text-xs\" htmlFor={controlId}>\n        <span className=\"font-mono text-sm wrap-anywhere\">{parameter.name}</span>\n        <span className=\"font-normal text-muted-foreground\">{parameter.kind}</span>\n        {parameter.required ? (\n          <span className=\"font-normal text-muted-foreground\">required</span>\n        ) : (\n          <span className=\"font-normal text-muted-foreground\">optional</span>\n        )}\n        {secret && <span className=\"font-normal text-muted-foreground\">secret</span>}\n      </Label>\n\n      <div className=\"flex min-w-0 items-center gap-1.5\">\n        {asMenu ? (\n          <DropdownMenu>\n            <DropdownMenuTrigger asChild>\n              <Button\n                aria-describedby={describedBy}\n                aria-invalid={invalid || undefined}\n                className={cn(\"h-8 w-full justify-between font-mono text-xs\", focusRing)}\n                id={controlId}\n                ref={node => {\n                  register(key, node)\n                }}\n                type=\"button\"\n                variant=\"outline\"\n              >\n                <span className=\"min-w-0 truncate\">{shown === \"\" ? \"Not set\" : shown}</span>\n                <ChevronDown aria-hidden=\"true\" className=\"opacity-60\" />\n              </Button>\n            </DropdownMenuTrigger>\n            <DropdownMenuContent>\n              <DropdownMenuRadioGroup onValueChange={next => onChange(key, next)} value={value}>\n                {!parameter.required && (\n                  <DropdownMenuRadioItem className=\"font-mono text-xs\" value=\"\">\n                    Not set\n                  </DropdownMenuRadioItem>\n                )}\n                {choices.map(choice => (\n                  <DropdownMenuRadioItem className=\"font-mono text-xs\" key={choice} value={choice}>\n                    {choice}\n                  </DropdownMenuRadioItem>\n                ))}\n              </DropdownMenuRadioGroup>\n            </DropdownMenuContent>\n          </DropdownMenu>\n        ) : (\n          <Input\n            aria-describedby={describedBy}\n            aria-invalid={invalid || undefined}\n            aria-required={parameter.required || undefined}\n            autoComplete=\"off\"\n            className=\"font-mono text-xs\"\n            id={controlId}\n            inputMode={parameter.kind === \"number\" ? \"decimal\" : undefined}\n            onChange={event => onChange(key, event.target.value)}\n            placeholder={parameter.placeholder}\n            ref={node => {\n              register(key, node)\n            }}\n            spellCheck={false}\n            // A masked field is a password field: it must not be offered to a\n            // password manager, and it must not survive a form autofill.\n            type={secret && !revealed ? \"password\" : \"text\"}\n            value={value}\n          />\n        )}\n        {secret && (\n          <Button\n            aria-label={revealed ? `Hide ${parameter.name}` : `Reveal ${parameter.name}`}\n            aria-pressed={revealed}\n            className={focusRing}\n            onClick={() => onReveal(key)}\n            size=\"icon-sm\"\n            type=\"button\"\n            variant=\"ghost\"\n          >\n            {revealed ? <EyeOff aria-hidden=\"true\" /> : <Eye aria-hidden=\"true\" />}\n          </Button>\n        )}\n      </div>\n\n      {parameter.description !== undefined && (\n        <p className=\"text-xs text-muted-foreground wrap-anywhere\" id={describeId}>\n          {parameter.description}\n        </p>\n      )}\n      {invalid && (\n        <p className=\"text-xs text-destructive wrap-anywhere\" id={errorId}>\n          {error}\n        </p>\n      )}\n    </div>\n  )\n}\n\n/* --------------------------------------------------------------- component */\n\ntype SendPhase =\n  | { kind: \"idle\"; stamp: number }\n  | { kind: \"sending\"; stamp: number }\n  | { kind: \"done\"; response: ApiResponse; stamp: number }\n  | { kind: \"failed\"; message: string; stamp: number }\n  | { kind: \"cancelled\"; stamp: number }\n\nexport interface ApiPlaygroundProps\n  extends ApiPlaygroundData,\n    Omit<React.HTMLAttributes<HTMLElement>, \"title\"> {\n  /**\n   * The only way a request ever leaves the browser. There is no fetch inside\n   * this component: the host owns auth, proxying, CORS and retries. Honour the\n   * signal — the console aborts it on cancel, on unmount and when the operation\n   * changes — and measure `durationMs` yourself.\n   */\n  onSend?: (request: ApiRequest, signal: AbortSignal) => Promise<ApiResponse>\n  /** Error branch only. Omit it and no retry button is painted. */\n  onRetry?: () => void\n  /** Render the copyable cURL snippet. Default true. */\n  showCurl?: boolean\n  /** Rows in the body editor, clamped 3-40. Default 8. */\n  bodyRows?: number\n  /** Parameter rows drawn in the loading branch, clamped 1-12. Default 4. */\n  skeletonRows?: number\n  /** Response headers open on first render. Default false. */\n  defaultResponseHeadersOpen?: boolean\n  /** Text of the send button. Default \"Send\". */\n  sendLabel?: string\n}\n\n/**\n * The try-it console: a method and path, editors generated from the operation's\n * parameter list, a body editor, one Send, and a response pane with status,\n * timing, size, headers and a pretty/raw toggle.\n *\n * Four rules shape it:\n *\n * 1. **The request leaves through an injected fetcher.** No `fetch` lives here,\n *    so auth, proxying and CORS stay the host's business and the block is\n *    testable with a function that resolves a literal.\n * 2. **Two state machines, never one.** `status` is the state of the operation\n *    DEFINITION (loading / empty / error / ready); the request has its own phase\n *    (idle / sending / done / failed / cancelled). Collapsing them is what makes\n *    consoles claim \"no endpoint\" while a request is in flight.\n * 3. **The clock is an input.** `durationMs` is measured by the fetcher, so the\n *    same response always renders the same figures, on the server too.\n * 4. **Secrets are masked on the way out, never on the way in.** The fetcher\n *    gets the real header value; the copyable snippet gets `$X_API_KEY`.\n */\nexport const ApiPlayground = React.forwardRef<HTMLElement, ApiPlaygroundProps>(\n  (\n    {\n      bodyRows = DEFAULT_BODY_ROWS,\n      className,\n      defaultResponseHeadersOpen = false,\n      errorMessage,\n      lastResponse,\n      onKeyDown,\n      onRetry,\n      onSend,\n      operation,\n      sendLabel = \"Send\",\n      showCurl = true,\n      skeletonRows = 4,\n      status,\n      title,\n      ...rest\n    },\n    ref,\n  ) => {\n    const baseId = React.useId()\n    const headingId = `${baseId}-title`\n    const problemsId = `${baseId}-problems`\n    const bodyId = `${baseId}-body`\n    const bodyErrorId = `${baseId}-body-error`\n    const headersPanelId = `${baseId}-response-headers`\n\n    // \"ready\" with no operation is the empty branch: a console with nothing to\n    // call is not ready, it is idle.\n    const branch = status === \"ready\" && operation === null ? \"empty\" : status\n\n    // One key for the whole draft. It changes when the host swaps the operation\n    // or flips the fetch state, and that is the ONLY thing that resets the form —\n    // a parent re-render must never wipe what the user typed.\n    const seed = `${status}:${operation?.id ?? \"\"}`\n    const [draftSeed, setDraftSeed] = React.useState(seed)\n    const [values, setValues] = React.useState<Record<string, string>>(() => initialValues(operation))\n    const [bodyText, setBodyText] = React.useState(() => operation?.body?.template ?? \"\")\n    const [revealed, setRevealed] = React.useState<Record<string, boolean>>({})\n    const [attempted, setAttempted] = React.useState(false)\n    const [phase, setPhase] = React.useState<SendPhase>(() =>\n      lastResponse === null ? { kind: \"idle\", stamp: 0 } : { kind: \"done\", response: lastResponse, stamp: 0 },\n    )\n    const [view, setView] = React.useState<\"pretty\" | \"raw\">(\"pretty\")\n    const [headersOpen, setHeadersOpen] = React.useState(defaultResponseHeadersOpen)\n    const [retryAsked, setRetryAsked] = React.useState<string | null>(null)\n\n    if (draftSeed !== seed) {\n      // Re-initialising during render rather than in an effect: an effect would\n      // paint one frame of the previous operation's values into the new form.\n      setDraftSeed(seed)\n      setValues(initialValues(operation))\n      setBodyText(operation?.body?.template ?? \"\")\n      setRevealed({})\n      setAttempted(false)\n      setPhase(\n        lastResponse === null ? { kind: \"idle\", stamp: 0 } : { kind: \"done\", response: lastResponse, stamp: 0 },\n      )\n    }\n\n    /** Synchronous one-shot guard. A second click lands before React re-renders. */\n    const sendLockRef = React.useRef(false)\n    const abortRef = React.useRef<AbortController | null>(null)\n    /** Monotonic; a settle whose id is stale belongs to a request nobody is waiting for. */\n    const stampRef = React.useRef(0)\n    const retryLockRef = React.useRef<string | null>(null)\n    const sendButtonRef = React.useRef<HTMLButtonElement | null>(null)\n    const cancelButtonRef = React.useRef<HTMLButtonElement | null>(null)\n    const bodyRef = React.useRef<HTMLTextAreaElement | null>(null)\n    const problemsRef = React.useRef<HTMLParagraphElement | null>(null)\n    const fieldRefs = React.useRef(new Map<string, HTMLElement>())\n\n    const register = React.useCallback((key: string, node: HTMLElement | null) => {\n      // Deleting on unmount keeps the map from pinning detached nodes when the\n      // operation swaps out under it.\n      if (node === null) fieldRefs.current.delete(key)\n      else fieldRefs.current.set(key, node)\n    }, [])\n\n    // Runs on unmount AND whenever the operation identity changes: anything still\n    // in flight is invalidated first (so its settle is dropped) and then aborted.\n    React.useEffect(\n      () => () => {\n        stampRef.current += 1\n        sendLockRef.current = false\n        abortRef.current?.abort()\n        abortRef.current = null\n      },\n      [seed],\n    )\n\n    const issues = React.useMemo(\n      () => (operation === null ? NO_ISSUES : validateRequest(operation, values, bodyText)),\n      [bodyText, operation, values],\n    )\n    const blocked = hasIssues(issues)\n    const request = React.useMemo(\n      () => (operation === null ? null : buildRequest(operation, values, bodyText)),\n      [bodyText, operation, values],\n    )\n    const curl = React.useMemo(\n      () => (operation === null ? \"\" : buildCurl(operation, values, bodyText)),\n      [bodyText, operation, values],\n    )\n\n    const settle = (stamp: number, next: SendPhase) => {\n      if (stamp !== stampRef.current) return\n      sendLockRef.current = false\n      abortRef.current = null\n      // Hand focus over BEFORE the cancel button unmounts: a control that\n      // disappears under the caret drops focus to <body> and the keyboard user\n      // loses the console entirely.\n      if (typeof document !== \"undefined\" && document.activeElement === cancelButtonRef.current) {\n        sendButtonRef.current?.focus()\n      }\n      setPhase(next)\n    }\n\n    const handleSend = () => {\n      if (operation === null || onSend === undefined) return\n      if (sendLockRef.current) return\n      if (blocked) {\n        // aria-disabled, not disabled: the press still reaches this handler, so\n        // it can explain itself and put the caret on the first thing to fix.\n        setAttempted(true)\n        const offending = Object.keys(issues.fields)\n        const firstField = operation.parameters.find(parameter =>\n          offending.includes(parameterKey(parameter)),\n        )\n        if (firstField !== undefined) fieldRefs.current.get(parameterKey(firstField))?.focus()\n        else if (issues.body !== null) bodyRef.current?.focus()\n        else problemsRef.current?.focus()\n        return\n      }\n\n      sendLockRef.current = true\n      const stamp = stampRef.current + 1\n      stampRef.current = stamp\n      const controller = new AbortController()\n      abortRef.current = controller\n      setAttempted(true)\n      setPhase({ kind: \"sending\", stamp })\n\n      const outgoing = buildRequest(operation, values, bodyText)\n      // Wrapped so a fetcher that throws synchronously is a failed request, not\n      // a render crash that takes the page with it.\n      Promise.resolve()\n        .then(() => onSend(outgoing, controller.signal))\n        .then(\n          response => settle(stamp, { kind: \"done\", response, stamp }),\n          (reason: unknown) =>\n            settle(\n              stamp,\n              controller.signal.aborted\n                ? { kind: \"cancelled\", stamp }\n                : { kind: \"failed\", message: describeError(reason), stamp },\n            ),\n        )\n    }\n\n    const handleCancel = () => {\n      if (!sendLockRef.current) return\n      const controller = abortRef.current\n      // The stamp moves first, so a fetcher that ignores its signal and resolves\n      // anyway can no longer paint a response the user already walked away from.\n      stampRef.current += 1\n      sendLockRef.current = false\n      abortRef.current = null\n      controller?.abort()\n      if (typeof document !== \"undefined\" && document.activeElement === cancelButtonRef.current) {\n        sendButtonRef.current?.focus()\n      }\n      setPhase({ kind: \"cancelled\", stamp: stampRef.current })\n    }\n\n    const retryKey = `${seed}:${errorMessage ?? \"\"}`\n    const retryLocked = retryAsked === retryKey\n    const handleRetry = () => {\n      if (onRetry === undefined || retryLockRef.current === retryKey) return\n      // No timer to re-arm: the lock lifts when the host actually reacts and the\n      // status, the operation or the message changes. Nothing to clean up.\n      retryLockRef.current = retryKey\n      setRetryAsked(retryKey)\n      onRetry()\n    }\n\n    const handleRootKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {\n      onKeyDown?.(event)\n      if (event.defaultPrevented) return\n      if (event.key === \"Enter\" && (event.metaKey || event.ctrlKey)) {\n        event.preventDefault()\n        handleSend()\n        return\n      }\n      if (event.key === \"Escape\" && phase.kind === \"sending\") {\n        // Only swallowed when it actually cancelled something, so a surrounding\n        // dialog keeps its own Escape the rest of the time.\n        event.stopPropagation()\n        handleCancel()\n      }\n    }\n\n    const sending = phase.kind === \"sending\"\n    const response = phase.kind === \"done\" ? phase.response : null\n    const prettyBody = React.useMemo(\n      () => (response === null ? null : prettyPrintJson(response.body)),\n      [response],\n    )\n    // A body that will not parse locks the toggle to raw rather than showing an\n    // empty \"pretty\" pane and pretending the payload was malformed.\n    const effectiveView = prettyBody === null ? \"raw\" : view\n    const shownBody = response === null ? \"\" : effectiveView === \"pretty\" && prettyBody !== null ? prettyBody : response.body\n    const derivedSize = response !== null && response.sizeBytes === null\n    const sizeBytes = response === null ? 0 : (response.sizeBytes ?? byteLength(response.body))\n    const kind = response === null ? \"unknown\" : responseClass(response.status)\n\n    const sentence = phaseSentence(branch, phase)\n    const signature = `${branch}|${phase.kind}:${phase.stamp}`\n    const [announcement, setAnnouncement] = React.useState(\"\")\n    const lastSignatureRef = React.useRef<string | null>(null)\n    React.useEffect(() => {\n      // The first paint is the baseline: a live region that recites the console\n      // on load is noise, not information.\n      if (lastSignatureRef.current === null) {\n        lastSignatureRef.current = signature\n        return\n      }\n      if (lastSignatureRef.current === signature) return\n      lastSignatureRef.current = signature\n      setAnnouncement(sentence)\n    }, [sentence, signature])\n\n    const rows = clampInt(bodyRows, MIN_BODY_ROWS, MAX_BODY_ROWS)\n    const skeletons = clampInt(skeletonRows, 1, 12)\n\n    return (\n      <section\n        aria-busy={branch === \"loading\" || undefined}\n        aria-labelledby={headingId}\n        className={cn(\"flex w-full flex-col gap-4 text-foreground\", className)}\n        onKeyDown={handleRootKeyDown}\n        ref={ref}\n        {...rest}\n      >\n        <div className=\"flex flex-wrap items-start justify-between gap-3\">\n          <div className=\"min-w-0\">\n            <h2 className=\"text-lg font-semibold wrap-anywhere\" id={headingId}>\n              {title}\n            </h2>\n            {operation?.summary !== undefined && (\n              <p className=\"text-sm text-muted-foreground wrap-anywhere\">{operation.summary}</p>\n            )}\n          </div>\n          {operation !== null && branch === \"ready\" && (\n            <Badge className={cn(\"rounded-md font-mono\", METHOD_TONE[operation.method])} variant=\"outline\">\n              {operation.method}\n            </Badge>\n          )}\n        </div>\n\n        {branch === \"loading\" && (\n          <div aria-hidden=\"true\" className=\"flex flex-col gap-4\">\n            <div className=\"rounded-xl border bg-card p-4\">\n              <div className=\"h-5 w-2/3 animate-pulse rounded bg-muted motion-reduce:animate-none\" />\n              <div className=\"mt-4 flex flex-col gap-3\">\n                {Array.from({ length: skeletons }, (_, index) => (\n                  <div className=\"flex flex-col gap-1.5\" key={index}>\n                    <div className=\"h-3 w-28 animate-pulse rounded bg-muted motion-reduce:animate-none\" />\n                    <div className=\"h-8 animate-pulse rounded-lg bg-muted/60 motion-reduce:animate-none\" />\n                  </div>\n                ))}\n              </div>\n            </div>\n            <div className=\"h-24 animate-pulse rounded-xl border bg-muted/40 motion-reduce:animate-none\" />\n          </div>\n        )}\n\n        {branch === \"empty\" && (\n          <Panel>\n            <CircleDashed aria-hidden=\"true\" className=\"size-6 text-muted-foreground\" />\n            <p className=\"text-sm font-medium\">No operation selected</p>\n            <p className=\"max-w-prose text-sm text-muted-foreground\">\n              Pick an endpoint and its parameters, headers and body editors are generated here from\n              the operation definition.\n            </p>\n          </Panel>\n        )}\n\n        {branch === \"error\" && (\n          <Panel className=\"border-destructive/40\">\n            <OctagonAlert aria-hidden=\"true\" className=\"size-6 text-destructive\" />\n            <p className=\"text-sm font-medium\">The operation definition could not be loaded</p>\n            <p className=\"max-w-prose text-sm text-muted-foreground wrap-anywhere\">\n              {errorMessage ?? \"Nothing can be sent until the endpoint definition arrives.\"}\n            </p>\n            {onRetry !== undefined && (\n              <Button\n                aria-disabled={retryLocked || undefined}\n                className={cn(\"mt-2\", softButton, focusRing)}\n                onClick={handleRetry}\n                size=\"sm\"\n                type=\"button\"\n                variant=\"outline\"\n              >\n                <RotateCw aria-hidden=\"true\" />\n                {retryLocked ? \"Reload requested\" : \"Reload definition\"}\n              </Button>\n            )}\n          </Panel>\n        )}\n\n        {branch === \"ready\" && operation !== null && (\n          <>\n            <div className=\"flex flex-col gap-4 rounded-xl border bg-card p-4\">\n              <div className=\"flex flex-wrap items-start justify-between gap-3\">\n                <p className=\"min-w-0 font-mono text-sm wrap-anywhere\">\n                  <PathText missing={issues.unmatchedPath} path={operation.path} />\n                </p>\n                <div className=\"flex shrink-0 items-center gap-2\">\n                  {sending && onSend !== undefined && (\n                    <Button\n                      className={cn(softButton, focusRing)}\n                      onClick={handleCancel}\n                      ref={cancelButtonRef}\n                      size=\"sm\"\n                      type=\"button\"\n                      variant=\"outline\"\n                    >\n                      <X aria-hidden=\"true\" />\n                      Cancel\n                    </Button>\n                  )}\n                  {onSend !== undefined && (\n                    <Button\n                      aria-describedby={blocked ? problemsId : undefined}\n                      aria-disabled={blocked || sending || undefined}\n                      className={cn(softButton, focusRing)}\n                      onClick={handleSend}\n                      ref={sendButtonRef}\n                      size=\"sm\"\n                      type=\"button\"\n                    >\n                      {sending ? (\n                        <LoaderCircle aria-hidden=\"true\" className=\"animate-spin motion-reduce:animate-none\" />\n                      ) : (\n                        <Play aria-hidden=\"true\" />\n                      )}\n                      {sending ? \"Sending…\" : sendLabel}\n                    </Button>\n                  )}\n                </div>\n              </div>\n\n              <p className=\"rounded-lg bg-muted/50 px-3 py-2 font-mono text-xs text-muted-foreground wrap-anywhere\">\n                {request?.url}\n              </p>\n\n              {GROUPS.map(group => {\n                const parameters = operation.parameters.filter(parameter => parameter.in === group.in)\n                if (parameters.length === 0) return null\n                const groupId = `${baseId}-group-${group.in}`\n                return (\n                  <div aria-labelledby={groupId} className=\"flex flex-col gap-3\" key={group.in} role=\"group\">\n                    <h3\n                      className=\"text-xs font-semibold tracking-wide text-muted-foreground uppercase\"\n                      id={groupId}\n                    >\n                      {group.label}\n                    </h3>\n                    <div className=\"grid gap-3 sm:grid-cols-2\">\n                      {parameters.map(parameter => {\n                        const key = parameterKey(parameter)\n                        return (\n                          <ParameterField\n                            error={issues.fields[key]}\n                            idBase={baseId}\n                            key={key}\n                            onChange={(changedKey, next) =>\n                              setValues(previous => ({ ...previous, [changedKey]: next }))\n                            }\n                            onReveal={revealKey =>\n                              setRevealed(previous => ({ ...previous, [revealKey]: !previous[revealKey] }))\n                            }\n                            parameter={parameter}\n                            register={register}\n                            revealed={revealed[key] === true}\n                            showError={attempted}\n                            value={values[key] ?? \"\"}\n                          />\n                        )\n                      })}\n                    </div>\n                  </div>\n                )\n              })}\n\n              {operation.body !== null && (\n                <div className=\"flex flex-col gap-1.5\">\n                  <Label className=\"flex-wrap gap-1.5 text-xs\" htmlFor={bodyId}>\n                    <span className=\"text-sm\">Body</span>\n                    <span className=\"font-mono font-normal text-muted-foreground\">\n                      {operation.body.contentType}\n                    </span>\n                    <span className=\"font-normal text-muted-foreground\">\n                      {operation.body.required ? \"required\" : \"optional\"}\n                    </span>\n                  </Label>\n                  <textarea\n                    aria-describedby={attempted && issues.body !== null ? bodyErrorId : undefined}\n                    aria-invalid={(attempted && issues.body !== null) || undefined}\n                    className={cn(\n                      \"w-full rounded-lg border border-input bg-transparent px-2.5 py-2 font-mono text-xs\",\n                      \"outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50\",\n                      \"aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20\",\n                      \"dark:bg-input/30\",\n                    )}\n                    id={bodyId}\n                    onChange={event => setBodyText(event.target.value)}\n                    ref={bodyRef}\n                    rows={rows}\n                    spellCheck={false}\n                    value={bodyText}\n                  />\n                  {operation.body.description !== undefined && (\n                    <p className=\"text-xs text-muted-foreground wrap-anywhere\">\n                      {operation.body.description}\n                    </p>\n                  )}\n                  {attempted && issues.body !== null && (\n                    <p className=\"text-xs text-destructive wrap-anywhere\" id={bodyErrorId}>\n                      {issues.body}\n                    </p>\n                  )}\n                </div>\n              )}\n\n              {/* Mounted for as long as the request is unsendable, not just after a\n                  press: the blocked handler focuses it, and an element that only\n                  appears in the NEXT render cannot be focused in this one. Before\n                  the first press it states the count in a neutral tone; a press\n                  turns it into the correction. */}\n              {blocked && (\n                <p\n                  className={cn(\n                    \"rounded-lg border px-3 py-2 text-xs wrap-anywhere\",\n                    attempted || issues.unmatchedPath.length > 0\n                      ? \"border-destructive/40 bg-destructive/5 text-destructive\"\n                      : \"bg-muted/50 text-muted-foreground\",\n                  )}\n                  id={problemsId}\n                  ref={problemsRef}\n                  tabIndex={-1}\n                >\n                  {issues.unmatchedPath.length > 0\n                    ? `The path uses ${issues.unmatchedPath.map(name => `{${name}}`).join(\", \")}, which no parameter declares. Nothing can fill it in.`\n                    : attempted\n                      ? \"Fix the highlighted fields before sending.\"\n                      : `${pendingCount(issues)} still to fill in before this can be sent.`}\n                </p>\n              )}\n\n              {showCurl && (\n                <div className=\"flex flex-col gap-2 border-t pt-3\">\n                  <div className=\"flex flex-wrap items-center justify-between gap-2\">\n                    <span className=\"flex items-center gap-1.5 text-xs font-semibold tracking-wide text-muted-foreground uppercase\">\n                      <Terminal aria-hidden=\"true\" className=\"size-3.5\" />\n                      cURL\n                    </span>\n                    <CopyAction label=\"Copy as cURL\" text={curl} />\n                  </div>\n                  <pre className=\"max-h-56 overflow-auto rounded-lg bg-muted/50 px-3 py-2 font-mono text-xs whitespace-pre-wrap text-muted-foreground wrap-anywhere\">\n                    {curl}\n                  </pre>\n                  {operation.parameters.some(parameter => parameter.secret === true) && (\n                    <p className=\"text-xs text-muted-foreground wrap-anywhere\">\n                      Secrets are substituted with shell variables, so the snippet is safe to paste\n                      into an issue and still runs once you export them.\n                    </p>\n                  )}\n                </div>\n              )}\n            </div>\n\n            <div className=\"flex flex-col gap-3 rounded-xl border bg-card p-4\">\n              <h3 className=\"text-sm font-semibold\">Response</h3>\n\n              {phase.kind === \"idle\" && (\n                <p className=\"text-sm text-muted-foreground\">\n                  Nothing sent yet. The status, timing, size and headers of the reply land here.\n                </p>\n              )}\n\n              {phase.kind === \"sending\" && (\n                <div className=\"flex flex-col gap-2\">\n                  <p className=\"flex items-center gap-2 text-sm text-muted-foreground\">\n                    <LoaderCircle\n                      aria-hidden=\"true\"\n                      className=\"size-4 animate-spin motion-reduce:animate-none\"\n                    />\n                    Waiting for the response…\n                  </p>\n                  <div\n                    aria-hidden=\"true\"\n                    className=\"h-24 animate-pulse rounded-lg bg-muted/60 motion-reduce:animate-none\"\n                  />\n                </div>\n              )}\n\n              {phase.kind === \"cancelled\" && (\n                <p className=\"flex items-center gap-2 text-sm text-muted-foreground\">\n                  <Ban aria-hidden=\"true\" className=\"size-4\" />\n                  Request cancelled. Nothing is claimed about whether the server ran it.\n                </p>\n              )}\n\n              {phase.kind === \"failed\" && (\n                <div className=\"flex flex-col gap-1 rounded-lg border border-destructive/40 bg-destructive/5 px-3 py-2\">\n                  <p className=\"flex items-center gap-2 text-sm font-medium text-destructive\">\n                    <OctagonAlert aria-hidden=\"true\" className=\"size-4\" />\n                    The request never completed\n                  </p>\n                  <p className=\"text-xs text-muted-foreground wrap-anywhere\">{phase.message}</p>\n                </div>\n              )}\n\n              {response !== null && (\n                <>\n                  <div className=\"flex flex-wrap items-center gap-2\">\n                    <Badge\n                      className={cn(\n                        \"h-auto rounded-md py-0.5 font-mono whitespace-normal wrap-anywhere\",\n                        CLASS_TONE[kind],\n                      )}\n                      variant=\"outline\"\n                    >\n                      {Number.isFinite(response.status) ? response.status : \"—\"} {response.statusText}\n                    </Badge>\n                    <span className=\"text-xs text-muted-foreground\">{CLASS_WORD[kind]}</span>\n                    <span className=\"text-xs tabular-nums text-muted-foreground\">\n                      {formatDuration(response.durationMs)}\n                    </span>\n                    <span className=\"text-xs tabular-nums text-muted-foreground\">\n                      {formatBytes(sizeBytes)}\n                      {derivedSize && \" (from body text)\"}\n                    </span>\n                  </div>\n\n                  <div className=\"flex flex-wrap items-center justify-between gap-2\">\n                    <div className=\"flex items-center gap-1\">\n                      {([\"pretty\", \"raw\"] as const).map(mode => {\n                        const locked = mode === \"pretty\" && prettyBody === null\n                        return (\n                          <Button\n                            aria-disabled={locked || undefined}\n                            aria-pressed={effectiveView === mode}\n                            className={cn(softButton, focusRing)}\n                            key={mode}\n                            onClick={() => {\n                              if (locked) return\n                              setView(mode)\n                            }}\n                            size=\"xs\"\n                            type=\"button\"\n                            variant={effectiveView === mode ? \"secondary\" : \"ghost\"}\n                          >\n                            {mode === \"pretty\" ? \"Pretty\" : \"Raw\"}\n                          </Button>\n                        )\n                      })}\n                      {prettyBody === null && response.body.trim() !== \"\" && (\n                        <span className=\"text-xs text-muted-foreground\">Not JSON — showing raw</span>\n                      )}\n                    </div>\n                    <CopyAction label=\"Copy body\" text={shownBody} />\n                  </div>\n\n                  <pre className=\"max-h-72 overflow-auto rounded-lg bg-muted/50 px-3 py-2 font-mono text-xs whitespace-pre-wrap wrap-anywhere\">\n                    {response.body.trim() === \"\" ? \"(empty body)\" : shownBody}\n                  </pre>\n\n                  <div>\n                    <Button\n                      aria-controls={headersPanelId}\n                      aria-expanded={headersOpen}\n                      className={cn(softButton, focusRing)}\n                      onClick={() => setHeadersOpen(open => !open)}\n                      size=\"xs\"\n                      type=\"button\"\n                      variant=\"ghost\"\n                    >\n                      <ChevronDown\n                        aria-hidden=\"true\"\n                        className={cn(\n                          \"transition-transform motion-reduce:transition-none\",\n                          headersOpen && \"rotate-180\",\n                        )}\n                      />\n                      Response headers ({response.headers.length})\n                    </Button>\n                    {/* `hidden`, not a collapsed track: a zero-height panel still\n                        holds its tab stops and becomes an invisible keyboard trap.\n                        The panel deliberately carries no `display:` utility either —\n                        any of them would outrank the `hidden` attribute. */}\n                    <div className=\"mt-2\" hidden={!headersOpen} id={headersPanelId}>\n                      {response.headers.length === 0 ? (\n                        <p className=\"text-xs text-muted-foreground\">\n                          The transport reported no headers.\n                        </p>\n                      ) : (\n                        <dl className=\"grid gap-x-4 gap-y-1 sm:grid-cols-[auto_1fr]\">\n                          {/* Duplicates are legal — Set-Cookie repeats — so the key\n                              carries the index, never the name alone. */}\n                          {response.headers.map((header, index) => (\n                            <React.Fragment key={`${header.name}-${index}`}>\n                              <dt className=\"font-mono text-xs text-muted-foreground wrap-anywhere\">\n                                {header.name}\n                              </dt>\n                              <dd className=\"font-mono text-xs wrap-anywhere\">{header.value}</dd>\n                            </React.Fragment>\n                          ))}\n                        </dl>\n                      )}\n                    </div>\n                  </div>\n                </>\n              )}\n            </div>\n          </>\n        )}\n\n        <span aria-atomic=\"true\" className=\"sr-only\" role=\"status\">\n          {announcement}\n        </span>\n      </section>\n    )\n  },\n)\n\nApiPlayground.displayName = \"ApiPlayground\"\n\nfunction initialValues(operation: ApiOperation | null): Record<string, string> {\n  const values: Record<string, string> = {}\n  if (operation === null) return values\n  for (const parameter of operation.parameters) {\n    values[parameterKey(parameter)] = parameter.initialValue ?? \"\"\n  }\n  return values\n}\n\n/** A rejected fetcher can throw anything; the pane still needs one printable line. */\nfunction describeError(reason: unknown): string {\n  if (reason instanceof Error) return reason.message\n  if (typeof reason === \"string\" && reason !== \"\") return reason\n  return \"The fetcher rejected without a message.\"\n}\n\nfunction phaseSentence(branch: ApiPlaygroundData[\"status\"], phase: SendPhase): string {\n  if (branch === \"loading\") return \"Loading the operation definition.\"\n  if (branch === \"empty\") return \"No operation is selected.\"\n  if (branch === \"error\") return \"The operation definition could not be loaded.\"\n  switch (phase.kind) {\n    case \"sending\":\n      return \"Sending the request.\"\n    case \"cancelled\":\n      return \"Request cancelled.\"\n    case \"failed\":\n      return `The request never completed. ${phase.message}`\n    case \"done\": {\n      const size = phase.response.sizeBytes ?? byteLength(phase.response.body)\n      return `${phase.response.status} ${phase.response.statusText}. ${CLASS_WORD[responseClass(phase.response.status)]}. ${formatDuration(phase.response.durationMs)}, ${formatBytes(size)}.`\n    }\n    default:\n      return \"Ready to send.\"\n  }\n}\n\nexport default ApiPlayground\n",
      "type": "registry:block"
    },
    {
      "path": "src/registry/blocks/api-playground.contract.ts",
      "content": "import { z } from \"zod\"\n\n/**\n * Methods the console can send. HEAD and OPTIONS are included because a try-it\n * panel gets pointed at preflights and probes; they simply carry no body.\n */\nexport const httpMethodSchema = z.enum([\"GET\", \"POST\", \"PUT\", \"PATCH\", \"DELETE\", \"HEAD\", \"OPTIONS\"])\n\n/** Where a parameter ends up on the wire. One array carries all three. */\nexport const parameterLocationSchema = z.enum([\"path\", \"query\", \"header\"])\n\n/** Which editor a parameter gets, and which check it has to answer to. */\nexport const parameterKindSchema = z.enum([\"string\", \"number\", \"boolean\", \"enum\"])\n\nexport const apiParameterSchema = z.object({\n  name: z.string(),\n  in: parameterLocationSchema,\n  kind: parameterKindSchema,\n  required: z.boolean(),\n  /** One line under the editor. */\n  description: z.string().optional(),\n  /**\n   * Value the editor opens on. EVERY value is edited as text whatever the kind —\n   * a half-typed number has to be representable, so the draft is never typed.\n   */\n  initialValue: z.string().optional(),\n  /**\n   * kind \"enum\" only. A missing or empty list falls back to a free text input\n   * rather than to a menu that cannot be opened.\n   */\n  options: z.array(z.string()).optional(),\n  placeholder: z.string().optional(),\n  /**\n   * A credential. The editor masks it behind a reveal toggle, and the copyable\n   * snippet substitutes a shell variable for the value instead of printing it.\n   */\n  secret: z.boolean().optional(),\n})\n\nexport const requestBodySchema = z.object({\n  /** Sent as Content-Type, and decides whether the body is checked as JSON. */\n  contentType: z.string(),\n  /** Text the body editor opens on, pre-formatted by the host and never re-indented here. */\n  template: z.string(),\n  required: z.boolean(),\n  description: z.string().optional(),\n})\n\nexport const apiOperationSchema = z.object({\n  id: z.string(),\n  method: httpMethodSchema,\n  /** Origin plus any prefix. Trailing slashes are trimmed when the URL is joined. */\n  baseUrl: z.string(),\n  /**\n   * Template path with {placeholders}, e.g. \"/v1/customers/{customerId}/invoices\".\n   * A placeholder with no matching path parameter stays literal and is reported\n   * as a blocking problem — silently sending \"{customerId}\" is worse.\n   */\n  path: z.string(),\n  summary: z.string().optional(),\n  parameters: z.array(apiParameterSchema),\n  /** null for an operation that carries no body. */\n  body: requestBodySchema.nullable(),\n})\n\nexport const responseHeaderSchema = z.object({ name: z.string(), value: z.string() })\n\nexport const apiResponseSchema = z.object({\n  /** HTTP status code. Anything outside 100-599 renders as an unknown class. */\n  status: z.number(),\n  statusText: z.string(),\n  /**\n   * Wall-clock milliseconds MEASURED BY THE FETCHER. The console never reads a\n   * clock, so the same response always renders the same figures — on the server\n   * too, and in every screenshot.\n   */\n  durationMs: z.number(),\n  /**\n   * Bytes on the wire, or null when the transport could not report it; the pane\n   * then derives the size from the body text and says that it did.\n   */\n  sizeBytes: z.number().nullable(),\n  /** Any order, duplicates allowed — Set-Cookie legitimately repeats. */\n  headers: z.array(responseHeaderSchema),\n  /** Raw body exactly as it arrived. The pretty view is derived from it. */\n  body: z.string(),\n})\n\n/**\n * What the injected fetcher receives. Header values are REAL here: masking only\n * ever happens in the copyable snippet, never on the wire.\n */\nexport const apiRequestSchema = z.object({\n  method: httpMethodSchema,\n  url: z.string(),\n  headers: z.record(z.string(), z.string()),\n  body: z.string().nullable(),\n})\n\nexport const apiPlaygroundSchema = z.object({\n  /**\n   * Render state of the OPERATION DEFINITION, not of the request. The request\n   * has its own phase (idle / sending / done / failed / cancelled) inside the\n   * console, and the two never share a variable.\n   */\n  status: z.enum([\"loading\", \"empty\", \"error\", \"ready\"]),\n  /** Console heading. Known before the definition arrives, so it renders in all four branches. */\n  title: z.string(),\n  operation: apiOperationSchema.nullable(),\n  /**\n   * A response already on hand — restored from a previous run or rendered on the\n   * server — so the pane has something to show before the first send. It is an\n   * INITIAL value, not a controlled one.\n   */\n  lastResponse: apiResponseSchema.nullable(),\n  /** Printed in the error branch instead of the generic sentence. */\n  errorMessage: z.string().optional(),\n})\n\nexport type HttpMethod = z.infer<typeof httpMethodSchema>\nexport type ParameterLocation = z.infer<typeof parameterLocationSchema>\nexport type ParameterKind = z.infer<typeof parameterKindSchema>\nexport type ApiParameter = z.infer<typeof apiParameterSchema>\nexport type RequestBody = z.infer<typeof requestBodySchema>\nexport type ApiOperation = z.infer<typeof apiOperationSchema>\nexport type ResponseHeader = z.infer<typeof responseHeaderSchema>\nexport type ApiResponse = z.infer<typeof apiResponseSchema>\nexport type ApiRequest = z.infer<typeof apiRequestSchema>\nexport type ApiPlaygroundData = z.infer<typeof apiPlaygroundSchema>\n",
      "type": "registry:block"
    }
  ],
  "type": "registry:block"
}
