{"slug":"console-error-card","category":"errors","job":"recover","added":"2026-09-14T10:42Z","title":"Console Error Card","description":"A structured card surfacing a browser console error with a code frame, call stack, and navigation.","url":"https://ai-patterns.app/patterns/errors/console-error-card","markdown":"https://ai-patterns.app/patterns/errors/console-error-card.md","json":"https://ai-patterns.app/api/patterns/errors/console-error-card","seenIn":[{"team":"lovable","source":"https://docs.lovable.dev/tips-tricks/troubleshooting"},{"team":"v0","source":"https://v0.app/docs/deployments"}],"spec":"# Console Error Card\n\n## Summary\nA structured card that surfaces a browser console error or warning inside an AI product's interface. Shows a human-readable error message, an inline code frame pointing to the offending line, a collapsible call stack, and optional navigation across multiple errors — letting the user inspect, copy, and dismiss without leaving the conversation.\n\n## When to use\n- When an AI coding assistant detects a runtime error in the user's running application and needs to show it contextually alongside the conversation.\n- When surfacing errors collected from a browser session, a test run, or a build log that the AI is helping the user debug.\n- When there are multiple errors to navigate through (1/N pattern) and the user needs to triage them one at a time.\n\n## When not to use\n- Quota or rate-limit errors — use the Rate Limit pattern instead.\n- Simple one-line error toasts that don't need code context — use a standard alert or toast.\n- Full-screen error pages (unrecoverable crashes) — this card is for inline, dismissible inspection, not for blocking states.\n- Compiler or build errors without a known line number — omit the code frame; don't show an empty frame.\n\n## Anatomy\n- **Top bar**: navigation arrows (previous / next) and a \"1/N\" counter for multi-error sessions; a severity badge (\"Console Error\" in red, \"Console Warning\" in amber); copy and close action buttons.\n- **Error message**: the raw error text, rendered in the severity color. Should be scannable at a glance.\n- **Code frame**: file path and line:col reference in the header; a small window of source lines with the error line highlighted and a \">\" gutter indicator; an optional \"Open in editor\" affordance.\n- **Call Stack**: a collapsible section with a frame count badge. Each frame shows the function name, an optional anonymous-context label, and the file path. Hidden by default to reduce visual weight.\n- **Helpful footer**: thumbs-up / thumbs-down feedback, revealed only when `onHelpful` is wired up. Lets the product collect signal on error surface quality.\n\n## Behavior\n- Navigation arrows are disabled when at the first or last error; they are hidden entirely when `total` is 1.\n- The call stack collapses with a height animation (Motion `AnimatePresence`). Opening it does not shift the surrounding layout unexpectedly — use overflow-hidden during transition.\n- Copying triggers a brief checkmark-swap animation on the copy icon (≈1.5 s), then reverts. No toast is needed — the icon change is the confirmation.\n- The helpful-vote buttons toggle `aria-pressed` and apply a color accent on selection; voting again on the same choice has no effect (idempotent).\n- Closing fires `onClose` and is the caller's responsibility — the card itself does not unmount; wrap it in `AnimatePresence` if you need an exit animation.\n\n## Code frame guidelines\n- Show 3–6 lines of context around the error line; 2 lines before and 2 after is a good default.\n- Always include the line number and column in the file header (`path/to/file.tsx (line:col) @ FunctionName`).\n- Highlight only the single error line — highlighting a range suggests a range selection, which is misleading.\n- If the source is not available, omit the `frame` prop entirely rather than showing a placeholder.\n\n## Content guidelines\n- Error message: verbatim from the console, no paraphrasing. The stack trace may rephrase, but the top-level message must be exact so the user can search for it.\n- Badge label: \"Console Error\" or \"Console Warning\" — not \"Runtime Error\", \"JS Error\", or anything branded.\n- Call stack frame names: use the function/component name as it appears in the source, not the mangled bundler name. If the frame is anonymous, show `<anonymous>` literally.\n- \"Was this helpful?\" — only show this when the AI generated the error diagnosis. Don't ask for helpfulness on raw errors the AI simply forwarded.\n\n## Accessibility\n- The card root should have `role=\"alert\"` when it appears dynamically so screen readers announce it immediately.\n- Navigation buttons have `aria-label=\"Previous error\"` / `aria-label=\"Next error\"`.\n- The call-stack toggle button has `aria-expanded` tracking the open state.\n- Thumbs buttons have `aria-label` (\"Yes, helpful\" / \"Not helpful\") and `aria-pressed` for toggle semantics.\n- The code frame is `aria-hidden` to screen readers if the full error message already conveys the problem — avoid reading out raw code lines.\n\n## Related patterns\n- **Rate Limit** — Use it instead when the error is a quota being hit, not a runtime failure.\n- **Partial Response** — Use it instead when the response was cut short by token or context limits.\n- **Terminal Stream** — Use it instead when errors surface inside streaming build or test output.\n","code":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { ChevronLeft, ChevronRight, ChevronDown, Copy, ThumbsUp, ThumbsDown, X, ExternalLink } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface CodeLine {\n  number: number;\n  content: string;\n  isError?: boolean;\n}\n\nexport interface CodeFrame {\n  file: string;\n  lines: CodeLine[];\n}\n\nexport interface StackFrame {\n  name: string;\n  context?: string;\n  file?: string;\n}\n\nexport interface ConsoleErrorCardProps {\n  type?: \"error\" | \"warning\";\n  message: string;\n  frame?: CodeFrame;\n  stack?: StackFrame[];\n  current?: number;\n  total?: number;\n  onPrev?: () => void;\n  onNext?: () => void;\n  onClose?: () => void;\n  onCopy?: () => void;\n  onOpenFrame?: (file: string) => void;\n  onHelpful?: (value: boolean) => void;\n  className?: string;\n}\n\nexport function ConsoleErrorCard({\n  type = \"error\",\n  message,\n  frame,\n  stack = [],\n  current = 1,\n  total = 1,\n  onPrev,\n  onNext,\n  onClose,\n  onCopy,\n  onOpenFrame,\n  onHelpful,\n  className,\n}: ConsoleErrorCardProps) {\n  const [stackOpen, setStackOpen] = React.useState(false);\n  const [helpfulVote, setHelpfulVote] = React.useState<boolean | null>(null);\n  const [copied, setCopied] = React.useState(false);\n\n  function handleCopy() {\n    onCopy?.();\n    setCopied(true);\n    setTimeout(() => setCopied(false), 1500);\n  }\n\n  function handleHelpful(value: boolean) {\n    setHelpfulVote(value);\n    onHelpful?.(value);\n  }\n\n  const isError = type === \"error\";\n\n  return (\n    <div\n      className={cn(\n        \"w-full overflow-hidden rounded-2xl border border-border bg-background text-foreground\",\n        className\n      )}\n    >\n      {/* Top bar */}\n      <div className=\"flex items-center justify-between border-b border-border px-3.5 py-2.5\">\n        <div className=\"flex items-center gap-2\">\n          {total > 1 && (\n            <>\n              <button\n                type=\"button\"\n                onClick={onPrev}\n                disabled={current <= 1}\n                aria-label=\"Previous error\"\n                className=\"rounded-md p-0.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40\"\n              >\n                <ChevronLeft className=\"size-4\" aria-hidden />\n              </button>\n              <span className=\"font-mono text-[11px] text-muted-foreground tabular-nums\">\n                {current}/{total}\n              </span>\n              <button\n                type=\"button\"\n                onClick={onNext}\n                disabled={current >= total}\n                aria-label=\"Next error\"\n                className=\"rounded-md p-0.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40\"\n              >\n                <ChevronRight className=\"size-4\" aria-hidden />\n              </button>\n            </>\n          )}\n          <span\n            className={cn(\n              \"rounded-md px-2 py-0.5 text-[11px] font-semibold\",\n              isError\n                ? \"bg-red-100 text-red-700 dark:bg-red-950/60 dark:text-red-400\"\n                : \"bg-amber-100 text-amber-700 dark:bg-amber-950/60 dark:text-amber-400\"\n            )}\n          >\n            Console {isError ? \"Error\" : \"Warning\"}\n          </span>\n        </div>\n\n        <div className=\"flex items-center gap-1\">\n          {onCopy && (\n            <button\n              type=\"button\"\n              onClick={handleCopy}\n              aria-label=\"Copy error\"\n              className=\"rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground\"\n            >\n              <AnimatePresence mode=\"wait\" initial={false}>\n                {copied ? (\n                  <motion.svg\n                    key=\"check\"\n                    initial={{ scale: 0.7, opacity: 0 }}\n                    animate={{ scale: 1, opacity: 1 }}\n                    exit={{ scale: 0.7, opacity: 0 }}\n                    transition={{ duration: 0.15 }}\n                    className=\"size-4 text-emerald-500\"\n                    viewBox=\"0 0 16 16\"\n                    fill=\"none\"\n                  >\n                    <path d=\"M3 8l3.5 3.5L13 5\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n                  </motion.svg>\n                ) : (\n                  <motion.div key=\"copy\" initial={{ scale: 0.7, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.7, opacity: 0 }} transition={{ duration: 0.15 }}>\n                    <Copy className=\"size-4\" aria-hidden />\n                  </motion.div>\n                )}\n              </AnimatePresence>\n            </button>\n          )}\n          {onClose && (\n            <button\n              type=\"button\"\n              onClick={onClose}\n              aria-label=\"Close\"\n              className=\"rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground\"\n            >\n              <X className=\"size-4\" aria-hidden />\n            </button>\n          )}\n        </div>\n      </div>\n\n      {/* Error message */}\n      <div className=\"px-4 py-3\">\n        <p\n          className={cn(\n            \"text-xs leading-relaxed\",\n            isError ? \"text-red-600 dark:text-red-400\" : \"text-amber-600 dark:text-amber-400\"\n          )}\n        >\n          {message}\n        </p>\n      </div>\n\n      {/* Code frame */}\n      {frame && (\n        <div className=\"mx-4 mb-3 overflow-hidden rounded-xl border border-border bg-muted/40\">\n          <div className=\"flex items-center justify-between border-b border-border px-3 py-2\">\n            <span className=\"font-mono text-[11px] text-muted-foreground\">{frame.file}</span>\n            {onOpenFrame && (\n              <button\n                type=\"button\"\n                onClick={() => onOpenFrame(frame.file)}\n                aria-label=\"Open in editor\"\n                className=\"rounded p-0.5 text-muted-foreground transition-colors hover:text-foreground\"\n              >\n                <ExternalLink className=\"size-3.5\" aria-hidden />\n              </button>\n            )}\n          </div>\n          <div className=\"overflow-x-auto p-2 font-mono text-[11px] leading-5\">\n            {frame.lines.map((line) => (\n              <div\n                key={line.number}\n                className={cn(\n                  \"flex gap-3 rounded px-1\",\n                  line.isError\n                    ? \"bg-red-50 dark:bg-red-950/40\"\n                    : \"\"\n                )}\n              >\n                <span\n                  className={cn(\n                    \"w-6 shrink-0 select-none text-right tabular-nums\",\n                    line.isError\n                      ? \"text-red-400 dark:text-red-500\"\n                      : \"text-muted-foreground/50\"\n                  )}\n                >\n                  {line.isError ? \">\" : \"\"}{line.number}\n                </span>\n                <span className=\"whitespace-pre text-muted-foreground\">\n                  <CodeContent content={line.content} isError={line.isError} />\n                </span>\n              </div>\n            ))}\n          </div>\n        </div>\n      )}\n\n      {/* Call stack */}\n      {stack.length > 0 && (\n        <div className=\"mx-4 mb-3 overflow-hidden rounded-xl border border-border\">\n          <button\n            type=\"button\"\n            onClick={() => setStackOpen((v) => !v)}\n            aria-expanded={stackOpen}\n            className=\"flex w-full items-center justify-between px-3 py-2 text-left transition-colors hover:bg-muted/50\"\n          >\n            <div className=\"flex items-center gap-2\">\n              <span className=\"text-[11px] font-semibold text-foreground\">Call Stack</span>\n              <span className=\"rounded-md bg-muted px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground tabular-nums\">\n                {stack.length}\n              </span>\n            </div>\n            <ChevronDown\n              className={cn(\n                \"size-4 shrink-0 text-muted-foreground transition-transform\",\n                stackOpen && \"rotate-180\"\n              )}\n              aria-hidden\n            />\n          </button>\n\n          <AnimatePresence initial={false}>\n            {stackOpen && (\n              <motion.div\n                initial={{ height: 0, opacity: 0 }}\n                animate={{ height: \"auto\", opacity: 1 }}\n                exit={{ height: 0, opacity: 0 }}\n                transition={{ duration: 0.18, ease: \"easeInOut\" }}\n                className=\"overflow-hidden border-t border-border\"\n              >\n                <div className=\"divide-y divide-border\">\n                  {stack.map((frame, i) => (\n                    <div key={i} className=\"px-3 py-2\">\n                      <p className=\"text-[11px] font-medium text-foreground\">{frame.name}</p>\n                      {frame.context && (\n                        <p className=\"text-[11px] text-muted-foreground\">{frame.context}</p>\n                      )}\n                      {frame.file && (\n                        <p className=\"mt-0.5 font-mono text-[11px] text-muted-foreground/70\">{frame.file}</p>\n                      )}\n                    </div>\n                  ))}\n                </div>\n              </motion.div>\n            )}\n          </AnimatePresence>\n        </div>\n      )}\n\n      {/* Footer */}\n      {onHelpful && (\n        <div className=\"flex items-center justify-end gap-2 border-t border-border px-4 py-2.5\">\n          <span className=\"text-[11px] text-muted-foreground\">Was this helpful?</span>\n          <button\n            type=\"button\"\n            onClick={() => handleHelpful(true)}\n            aria-label=\"Yes, helpful\"\n            aria-pressed={helpfulVote === true}\n            className={cn(\n              \"rounded-md p-1 transition-colors\",\n              helpfulVote === true\n                ? \"text-emerald-500\"\n                : \"text-muted-foreground hover:text-foreground\"\n            )}\n          >\n            <ThumbsUp className=\"size-3.5\" aria-hidden />\n          </button>\n          <button\n            type=\"button\"\n            onClick={() => handleHelpful(false)}\n            aria-label=\"Not helpful\"\n            aria-pressed={helpfulVote === false}\n            className={cn(\n              \"rounded-md p-1 transition-colors\",\n              helpfulVote === false\n                ? \"text-red-500\"\n                : \"text-muted-foreground hover:text-foreground\"\n            )}\n          >\n            <ThumbsDown className=\"size-3.5\" aria-hidden />\n          </button>\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction CodeContent({ content, isError }: { content: string; isError?: boolean }) {\n  return (\n    <span className={isError ? \"text-foreground\" : undefined}>\n      {content}\n    </span>\n  );\n}\n","dependencies":["lucide-react","motion"],"localImports":["@/lib/utils"]}