{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"artifact-panel","type":"registry:block","title":"Artifact Panel","description":"Side-pane canvas for AI-generated artifacts: hairline panel shell, header with action cluster, preview/code view toggle with cross-fade, and a version navigator with restore.","author":"UI Lab","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"cssVars":{"light":{"wb-hover":"rgb(0 0 0 / 0.05)","wb-hover-subtle":"rgb(0 0 0 / 0.04)","wb-hover-strong":"rgb(0 0 0 / 0.1)","wb-hover-stronger":"rgb(0 0 0 / 0.1)","wb-inset":"rgb(0 0 0 / 0.05)","wb-inset-strong":"rgb(0 0 0 / 0.06)","wb-inset-subtle":"rgb(0 0 0 / 0.03)","wb-inset-faint":"rgb(0 0 0 / 0.02)","wb-code-inline":"rgb(0 0 0 / 0.07)","wb-code-block":"rgb(0 0 0 / 0.04)","wb-border":"rgb(0 0 0 / 0.12)","wb-border-subtle":"rgb(0 0 0 / 0.1)","wb-border-strong":"rgb(0 0 0 / 0.17)","wb-border-emphasis":"rgb(0 0 0 / 0.22)","wb-divider":"rgb(0 0 0 / 0.07)","wb-hairline":"rgb(0 0 0 / 0.12)","wb-hairline-soft":"rgb(0 0 0 / 0.1)","wb-hairline-subtle":"rgb(0 0 0 / 0.08)","wb-control-hairline":"rgb(0 0 0 / 0.08)","wb-control-tick":"rgb(0 0 0 / 0.2)","wb-resize-handle":"rgb(0 0 0 / 0.2)","wb-surface":"rgb(255 255 255)","wb-surface-translucent":"oklch(94.5% 0.002 250 / 0.75)","wb-overlay-scrim":"rgb(0 0 0 / 0.06)","wb-surface-raised":"rgb(255 255 255 / 0.95)","wb-surface-composer":"rgb(255 255 255 / 0.9)","wb-card":"rgb(0 0 0 / 0.04)","wb-inverse":"oklch(20.5% 0 0)","wb-inverse-fg":"rgb(255 255 255)","wb-accent-fg":"rgb(255 255 255)","wb-solid-control-fg":"oklch(20.5% 0 0)","wb-shimmer-dim":"rgb(0 0 0 / 0.4)","wb-shimmer-bright":"rgb(0 0 0 / 0.85)","wb-accent":"#339cff","wb-success":"oklch(59.6% 0.145 163.225)","wb-success-border":"oklch(69.6% 0.17 162.48)","wb-danger":"oklch(63.7% 0.237 25.331)","wb-danger-strong":"#fa423e","wb-danger-surface":"oklch(63.7% 0.237 25.331)","wb-warning":"#e25507","wb-warning-text":"oklch(64.6% 0.222 41.116)","wb-warning-hover":"oklch(70.5% 0.213 47.604)","wb-warning-surface":"#e25507"},"dark":{"wb-hover":"rgb(255 255 255 / 0.1)","wb-hover-subtle":"rgb(255 255 255 / 0.05)","wb-hover-strong":"rgb(255 255 255 / 0.1)","wb-hover-stronger":"rgb(255 255 255 / 0.15)","wb-inset":"rgb(255 255 255 / 0.05)","wb-inset-strong":"rgb(255 255 255 / 0.1)","wb-inset-subtle":"rgb(255 255 255 / 0.05)","wb-inset-faint":"rgb(255 255 255 / 0.03)","wb-code-inline":"rgb(255 255 255 / 0.1)","wb-code-block":"rgb(255 255 255 / 0.05)","wb-border":"rgb(255 255 255 / 0.1)","wb-border-subtle":"rgb(255 255 255 / 0.1)","wb-border-strong":"rgb(255 255 255 / 0.15)","wb-border-emphasis":"rgb(255 255 255 / 0.2)","wb-divider":"rgb(255 255 255 / 0.06)","wb-hairline":"rgb(255 255 255 / 0.157)","wb-hairline-soft":"rgb(255 255 255 / 0.15)","wb-hairline-subtle":"rgb(255 255 255 / 0.1)","wb-control-hairline":"rgb(255 255 255 / 0.08)","wb-control-tick":"rgb(255 255 255 / 0.3)","wb-resize-handle":"rgb(255 255 255 / 0.25)","wb-surface":"oklch(20.5% 0 0)","wb-surface-translucent":"oklch(20.5% 0 0 / 0.6)","wb-overlay-scrim":"rgb(0 0 0 / 0.24)","wb-surface-raised":"oklch(26.9% 0 0 / 0.95)","wb-surface-composer":"oklch(26.9% 0 0 / 0.9)","wb-card":"oklch(26.9% 0 0 / 0.5)","wb-inverse":"rgb(255 255 255)","wb-inverse-fg":"oklch(20.5% 0 0)","wb-accent-fg":"rgb(255 255 255)","wb-solid-control-fg":"oklch(20.5% 0 0)","wb-shimmer-dim":"rgb(255 255 255 / 0.4)","wb-shimmer-bright":"rgb(255 255 255 / 0.95)","wb-accent":"#339cff","wb-success":"#40c977","wb-success-border":"oklch(69.6% 0.17 162.48)","wb-danger":"#fa423e","wb-danger-strong":"#fa423e","wb-danger-surface":"oklch(63.7% 0.237 25.331)","wb-warning":"#ff8549","wb-warning-text":"#ff8549","wb-warning-hover":"#e25507","wb-warning-surface":"#e25507"}},"files":[{"path":"components/motion/artifact-panel/index.tsx","type":"registry:component","target":"@components/motion/artifact-panel/index.tsx","content":"\"use client\";\n// ui-lab-ten.vercel.app/components/blocks/artifact-panel\n\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport type { ReactNode } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface ArtifactPanelProps {\n  className?: string;\n  children?: ReactNode;\n}\n\n/**\n * Panel shell for a live artifact/canvas surface next to a conversation — the\n * hairline-ringed card that hosts a header, a scrollable content region and\n * (optionally) a version-nav footer. Uses the same CSS-variable box-shadow\n * hairline technique as `ThreadCard` / `WorkbenchSummaryCard`, tuned to a\n * full opaque surface rather than a tinted one since this panel is the\n * primary content, not an inline card in a message stream.\n */\nexport function ArtifactPanel({ className, children }: ArtifactPanelProps) {\n  return (\n    <div\n      style={{ boxShadow: \"0 0 0 0.5px var(--wb-hairline)\" }}\n      className={cn(\n        \"flex h-full min-h-0 flex-col overflow-hidden rounded-2xl bg-[var(--wb-surface)]\",\n        className,\n      )}\n    >\n      {children}\n    </div>\n  );\n}\n\nexport interface ArtifactHeaderProps {\n  /** 16px leading icon, e.g. `<FileCode2 className=\"h-4 w-4\" />`. */\n  icon?: ReactNode;\n  title: ReactNode;\n  /** Rendered beside the title on the same line, truncating independently. */\n  subtitle?: ReactNode;\n  /** Trailing slot for the caller's own controls — copy/download buttons, an `ArtifactViewToggle`. */\n  actions?: ReactNode;\n  className?: string;\n}\n\n/** Fixed 48px header row: icon, truncating title/subtitle, and a trailing action cluster. */\nexport function ArtifactHeader({ icon, title, subtitle, actions, className }: ArtifactHeaderProps) {\n  return (\n    <div\n      className={cn(\n        \"flex h-12 shrink-0 items-center gap-2.5 border-[var(--wb-divider)] border-b-[0.5px] px-3\",\n        className,\n      )}\n    >\n      {icon ? (\n        <span className=\"flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground\">\n          {icon}\n        </span>\n      ) : null}\n      <div className=\"flex min-w-0 flex-1 items-baseline gap-2\">\n        <span className=\"truncate text-sm font-medium\">{title}</span>\n        {subtitle ? <span className=\"truncate text-muted-foreground text-xs\">{subtitle}</span> : null}\n      </div>\n      {actions ? <div className=\"ml-auto flex shrink-0 items-center gap-0.5\">{actions}</div> : null}\n    </div>\n  );\n}\n\nexport interface ArtifactActionProps {\n  \"aria-label\": string;\n  onClick?: () => void;\n  children?: ReactNode;\n  className?: string;\n}\n\n/** One icon button inside an `ArtifactHeader`'s actions slot (copy, download, ...) — same visual as `ThreadActionButton`. */\nexport function ArtifactAction({\n  \"aria-label\": ariaLabel,\n  onClick,\n  children,\n  className,\n}: ArtifactActionProps) {\n  return (\n    <button\n      type=\"button\"\n      aria-label={ariaLabel}\n      onClick={onClick}\n      className={cn(\n        \"flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-[var(--wb-hover)] hover:text-foreground\",\n        className,\n      )}\n    >\n      {children}\n    </button>\n  );\n}\n\nexport type ArtifactView = \"preview\" | \"code\";\n\nexport interface ArtifactViewToggleProps {\n  value: ArtifactView;\n  onChange: (next: ArtifactView) => void;\n  previewLabel?: ReactNode;\n  codeLabel?: ReactNode;\n  className?: string;\n}\n\n/** Two-segment pill for switching between a rendered \"Preview\" and raw \"Code\" view — same visual language as the preview's scene-switcher pill. */\nexport function ArtifactViewToggle({\n  value,\n  onChange,\n  previewLabel = \"Preview\",\n  codeLabel = \"Code\",\n  className,\n}: ArtifactViewToggleProps) {\n  return (\n    <div\n      className={cn(\n        \"flex items-center gap-0.5 rounded-full border border-[var(--wb-border)] p-0.5\",\n        className,\n      )}\n    >\n      {([\"preview\", \"code\"] as const).map((view) => (\n        <button\n          key={view}\n          type=\"button\"\n          aria-pressed={value === view}\n          onClick={() => onChange(view)}\n          className={cn(\n            \"h-6 rounded-full px-2.5 text-xs transition-colors\",\n            value === view\n              ? \"bg-foreground text-background\"\n              : \"text-muted-foreground hover:text-foreground\",\n          )}\n        >\n          {view === \"preview\" ? previewLabel : codeLabel}\n        </button>\n      ))}\n    </div>\n  );\n}\n\nexport interface ArtifactContentProps {\n  /** When passed, `children` cross-fades keyed on this value (e.g. the active `ArtifactView`, or a composite key that also folds in a version so version switches transition too). Omit for static content that never swaps. */\n  view?: string | number;\n  className?: string;\n  children?: ReactNode;\n}\n\n/**\n * Scrollable body region of an `ArtifactPanel`. When `view` is passed, the\n * previous and next `children` cross-fade (opacity + a 4px rise, 0.15s\n * `EASE_OUT`, `AnimatePresence mode=\"wait\"`) instead of swapping instantly —\n * reduced to a plain opacity fade under `useReducedMotion()`. Without `view`,\n * `children` render directly with no transition wrapper.\n */\nexport function ArtifactContent({ view, className, children }: ArtifactContentProps) {\n  const reduce = useReducedMotion() ?? false;\n\n  return (\n    <div className={cn(\"relative min-h-0 flex-1 overflow-auto\", className)}>\n      {view !== undefined ? (\n        <AnimatePresence mode=\"wait\" initial={false}>\n          <motion.div\n            key={view}\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4 }}\n            animate={{ opacity: 1, y: 0 }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}\n            transition={{ duration: 0.15, ease: EASE_OUT }}\n          >\n            {children}\n          </motion.div>\n        </AnimatePresence>\n      ) : (\n        children\n      )}\n    </div>\n  );\n}\n\nexport interface ArtifactVersionNavProps {\n  /** 1-based position of the version currently shown. */\n  index: number;\n  count: number;\n  onPrev?: () => void;\n  onNext?: () => void;\n  /** Renders a trailing ghost \"Restore\" button when passed — the caller decides when restoring makes sense (e.g. only on non-latest versions). */\n  onRestore?: () => void;\n  restoreLabel?: ReactNode;\n  className?: string;\n}\n\n/**\n * Prev/next control for stepping through an artifact's version history — the\n * same shape as `ThreadBranchSwitcher`: chevron buttons flanking a\n * tabular-nums \"v2 / 3\" readout that crossfades with a short y-shift on\n * change (`AnimatePresence mode=\"popLayout\"`), reduced to an instant swap\n * under `useReducedMotion()`. An optional trailing ghost \"Restore\" button\n * appears whenever `onRestore` is passed.\n */\nexport function ArtifactVersionNav({\n  index,\n  count,\n  onPrev,\n  onNext,\n  onRestore,\n  restoreLabel = \"Restore\",\n  className,\n}: ArtifactVersionNavProps) {\n  const reduce = useReducedMotion() ?? false;\n\n  return (\n    <div className={cn(\"flex items-center gap-0.5 text-muted-foreground text-xs\", className)}>\n      <button\n        type=\"button\"\n        aria-label=\"Previous version\"\n        disabled={index <= 1}\n        onClick={onPrev}\n        className=\"flex h-5 w-5 items-center justify-center rounded hover:bg-[var(--wb-hover)] disabled:pointer-events-none disabled:opacity-30\"\n      >\n        <ChevronLeft className=\"h-3 w-3\" />\n      </button>\n      <span className=\"relative inline-flex h-4 min-w-[5ch] items-center justify-center overflow-hidden tabular-nums\">\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          <motion.span\n            key={`${index}/${count}`}\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4 }}\n            animate={{ opacity: 1, y: 0 }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}\n            transition={{ duration: 0.15, ease: EASE_OUT }}\n            className=\"absolute inset-0 flex items-center justify-center\"\n          >\n            v{index} / {count}\n          </motion.span>\n        </AnimatePresence>\n      </span>\n      <button\n        type=\"button\"\n        aria-label=\"Next version\"\n        disabled={index >= count}\n        onClick={onNext}\n        className=\"flex h-5 w-5 items-center justify-center rounded hover:bg-[var(--wb-hover)] disabled:pointer-events-none disabled:opacity-30\"\n      >\n        <ChevronRight className=\"h-3 w-3\" />\n      </button>\n      {onRestore ? (\n        <button\n          type=\"button\"\n          onClick={onRestore}\n          className=\"ml-1 rounded px-1 text-muted-foreground text-xs transition-colors hover:text-foreground\"\n        >\n          {restoreLabel}\n        </button>\n      ) : null}\n    </div>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}