{
  "name": "button",
  "type": "registry:ui",
  "title": "Button",
  "description": "Button is the standard control for visible actions, with built-in variants, sizes, loading behavior, and accessible disabled states.",
  "registryDependencies": [
    "@siteplane/base",
    "@siteplane/spinner"
  ],
  "dependencies": [
    "@base-ui/react@1.5.0",
    "class-variance-authority@0.7.1"
  ],
  "files": [
    {
      "path": "src/components/ui/button-recipes.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport {\n  useState,\n  type MouseEventHandler,\n  type ReactElement,\n  type ReactNode,\n} from \"react\";\n\nimport { Button, type ButtonProps } from \"@/components/ui/button\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport { cn } from \"@/lib/utils\";\n\ntype IconButtonSize = Extract<\n  NonNullable<ButtonProps[\"size\"]>,\n  \"icon\" | \"icon-lg\" | \"icon-sm\" | \"icon-xl\" | \"icon-xs\"\n>;\n\nexport type IconButtonProps = Omit<ButtonProps, \"aria-label\" | \"children\" | \"size\"> & {\n  icon: ReactNode;\n  label: string;\n  size?: IconButtonSize;\n};\n\nexport function IconButton({\n  icon,\n  label,\n  size = \"icon\",\n  ...props\n}: IconButtonProps): ReactElement {\n  return (\n    <Button aria-label={label} size={size} {...props}>\n      {icon}\n    </Button>\n  );\n}\n\nexport type ButtonWithIconProps = Omit<ButtonProps, \"children\"> & {\n  children: ReactNode;\n  icon: ReactNode;\n  iconPosition?: \"end\" | \"start\";\n};\n\nexport function ButtonWithIcon({\n  children,\n  icon,\n  iconPosition = \"start\",\n  ...props\n}: ButtonWithIconProps): ReactElement {\n  return (\n    <Button {...props}>\n      {iconPosition === \"start\" ? icon : null}\n      {children}\n      {iconPosition === \"end\" ? icon : null}\n    </Button>\n  );\n}\n\ntype LoadingButtonPhase = \"idle\" | \"loading\";\n\nexport type LoadingButtonProps = Omit<ButtonProps, \"children\" | \"loading\" | \"onClick\"> & {\n  action?: () => Promise<unknown> | unknown;\n  children: ReactNode;\n  icon?: ReactNode;\n  iconPosition?: \"end\" | \"start\";\n  loading?: boolean;\n  loadingText?: ReactNode;\n  onClick?: MouseEventHandler<HTMLButtonElement>;\n  onError?: (error: unknown) => void;\n};\n\nexport function LoadingButton({\n  action,\n  children,\n  className,\n  disabled,\n  icon,\n  iconPosition = \"start\",\n  loading = false,\n  loadingText = \"Saving\",\n  onClick,\n  onError,\n  ...props\n}: LoadingButtonProps): ReactElement {\n  const [phase, setPhase] = useState<LoadingButtonPhase>(\"idle\");\n  const visualPhase: LoadingButtonPhase = loading ? \"loading\" : phase;\n  const isPending = visualPhase === \"loading\";\n\n  const handleClick: MouseEventHandler<HTMLButtonElement> = async (event) => {\n    if (visualPhase !== \"idle\" || disabled) {\n      return;\n    }\n\n    onClick?.(event);\n\n    if (event.defaultPrevented || !action) {\n      return;\n    }\n\n    setPhase(\"loading\");\n\n    try {\n      await Promise.resolve(action());\n    } catch (error) {\n      onError?.(error);\n      setPhase(\"idle\");\n      return;\n    }\n\n    setPhase(\"idle\");\n  };\n\n  const content = (\n    <span className=\"relative inline-grid min-w-0 items-center justify-items-center\">\n      <span\n        aria-hidden={isPending || undefined}\n        className=\"siteplane-loading-button-idle inline-flex min-w-0 items-center gap-2\"\n      >\n        {iconPosition === \"start\" ? icon : null}\n        {children}\n        {iconPosition === \"end\" ? icon : null}\n      </span>\n      <span\n        aria-hidden={!isPending || undefined}\n        aria-live=\"polite\"\n        className=\"siteplane-loading-button-pending absolute inset-0 inline-flex min-w-0 items-center justify-center gap-1.5\"\n      >\n        <Spinner aria-label=\"Saving\" className=\"size-4 shrink-0\" />\n        <span className=\"truncate\">{loadingText}</span>\n      </span>\n    </span>\n  );\n\n  return (\n    <span\n      className=\"siteplane-loading-button-shell\"\n      data-loading-button-phase={visualPhase}\n    >\n      <Button\n        {...props}\n        aria-busy={isPending || undefined}\n        className={cn(\"w-full overflow-hidden\", className)}\n        disabled={Boolean(disabled || isPending)}\n        onClick={handleClick}\n      >\n        {content}\n      </Button>\n    </span>\n  );\n}\n"
    },
    {
      "path": "src/components/ui/button.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport { mergeProps } from \"@base-ui/react/merge-props\";\nimport { useRender } from \"@base-ui/react/use-render\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport type * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { useResolvedControlSize } from \"@/components/ui/siteplane-provider\";\nimport { Spinner } from \"@/components/ui/spinner\";\n\nexport const buttonVariants = cva(\n  \"relative inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-lg border font-medium text-base outline-none transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[inherit] pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-64 data-loading:select-none data-loading:text-transparent sm:text-sm [&_svg:not([class*='opacity-'])]:opacity-80 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:-mx-0.5 [&_svg]:shrink-0\",\n  {\n    defaultVariants: {\n      size: \"default\",\n      variant: \"default\",\n    },\n    variants: {\n      size: {\n        default: \"h-9 px-[calc(--spacing(3)-1px)] sm:h-8\",\n        icon: \"size-9 sm:size-8\",\n        \"icon-lg\": \"size-10 sm:size-9\",\n        \"icon-sm\": \"size-8 sm:size-7\",\n        \"icon-xl\":\n          \"size-14 [&_svg:not([class*='size-'])]:size-5 sm:[&_svg:not([class*='size-'])]:size-5\",\n        \"icon-xs\":\n          \"size-7 rounded-md before:rounded-[inherit] sm:size-6 not-in-data-[slot=input-group]:[&_svg:not([class*='size-'])]:size-4 sm:not-in-data-[slot=input-group]:[&_svg:not([class*='size-'])]:size-3.5\",\n        lg: \"h-10 px-[calc(--spacing(3.5)-1px)] sm:h-9\",\n        sm: \"h-8 gap-1.5 px-[calc(--spacing(2.5)-1px)] sm:h-7\",\n        xl: \"h-14 px-[calc(--spacing(4)-1px)] text-lg leading-6 sm:text-lg [&_svg:not([class*='size-'])]:size-5 sm:[&_svg:not([class*='size-'])]:size-5\",\n        xs: \"h-7 gap-1 rounded-md px-[calc(--spacing(2)-1px)] text-sm before:rounded-[inherit] sm:h-6 sm:text-xs [&_svg:not([class*='size-'])]:size-4 sm:[&_svg:not([class*='size-'])]:size-3.5\",\n      },\n      variant: {\n        default:\n          \"border-primary bg-primary text-primary-foreground hover:bg-primary/84 data-pressed:bg-primary/78 *:data-[slot=button-loading-indicator]:text-primary-foreground\",\n        destructive:\n          \"not-disabled:inset-shadow-[0_1px_--theme(--color-white/16%)] border-destructive bg-destructive text-white shadow-destructive/24 shadow-xs hover:bg-destructive/90 data-pressed:bg-destructive/90 *:data-[slot=button-loading-indicator]:text-white [:active,[data-pressed]]:inset-shadow-[0_1px_--theme(--color-black/8%)] [:disabled,:active,[data-pressed]]:shadow-none\",\n        \"destructive-outline\":\n          \"border-input bg-popover not-dark:bg-clip-padding text-destructive-foreground shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] hover:border-destructive/32 hover:bg-destructive/4 hover:shadow-sm/10 data-pressed:border-destructive/32 data-pressed:bg-destructive/4 *:data-[slot=button-loading-indicator]:text-foreground dark:bg-input/32 dark:not-disabled:before:shadow-[0_-1px_--theme(--color-white/2%)] dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [:disabled,:active,[data-pressed]]:shadow-none\",\n        ghost:\n          \"border-transparent text-[var(--button-text)] hover:bg-accent hover:text-[var(--button-text-hover)] data-pressed:bg-accent *:data-[slot=button-loading-indicator]:text-foreground\",\n        link: \"border-transparent text-[var(--button-text)] underline-offset-4 hover:text-[var(--button-text-hover)] hover:underline data-pressed:underline *:data-[slot=button-loading-indicator]:text-foreground\",\n        outline:\n          \"border-input bg-popover not-dark:bg-clip-padding text-[var(--button-text)] shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] hover:bg-accent/50 hover:text-[var(--button-text-hover)] hover:shadow-sm/10 data-pressed:bg-accent/50 *:data-[slot=button-loading-indicator]:text-foreground dark:bg-input/32 dark:data-pressed:bg-input/64 dark:hover:bg-input/64 dark:not-disabled:before:shadow-[0_-1px_--theme(--color-white/2%)] dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [:disabled,:active,[data-pressed]]:shadow-none\",\n        secondary:\n          \"border-transparent bg-secondary text-[var(--button-text)] hover:bg-foreground/10 hover:text-[var(--button-text-hover)] data-pressed:bg-foreground/14 *:data-[slot=button-loading-indicator]:text-secondary-foreground [:active,[data-pressed]]:bg-foreground/14\",\n        success:\n          \"border-success/32 bg-success/8 text-success-foreground hover:bg-success/16 data-pressed:bg-success/20 *:data-[slot=button-loading-indicator]:text-success-foreground dark:bg-success/16 dark:hover:bg-success/24 dark:data-pressed:bg-success/28\",\n      },\n    },\n  },\n);\n\nexport interface ButtonProps extends useRender.ComponentProps<\"button\"> {\n  variant?: VariantProps<typeof buttonVariants>[\"variant\"];\n  size?: VariantProps<typeof buttonVariants>[\"size\"];\n  loading?: boolean;\n  align?: \"center\" | \"start\" | \"between\";\n  multiline?: boolean;\n  width?: \"auto\" | \"fit\" | \"full\";\n  /**\n   * Schaltet nur die Hover- und Press-Scale aus. Farb-, Schatten-, Fokus- und\n   * Disabled-States bleiben erhalten. Praktisch fuer Buttons, die als Trigger\n   * Teil eines groesseren Custom-Elements sind.\n   */\n  scale?: boolean;\n  /**\n   * Sitzt buendig in seinem Container: waechst auf dessen Hoehe und gibt die\n   * eigenen Ecken auf. Fuer Leisten, in denen der Button die Kante bildet.\n   */\n  bleed?: boolean;\n  /**\n   * Der Button ist eine ganze Flaeche (Zeile, Karte) statt eines kompakten\n   * Controls. Dann gilt Flaechen-Feedback wie beim Collapsible-Trigger:\n   * dezenter Hover-Ton und kein Press-/Hover-Scale, weil das Skalieren einer\n   * vollbreiten Flaeche unruhig wirkt.\n   */\n  surface?: boolean;\n}\n\nexport function Button({\n  align = \"center\",\n  bleed = false,\n  className,\n  variant,\n  size,\n  render,\n  children,\n  loading = false,\n  multiline = false,\n  scale = true,\n  surface = false,\n  width = \"auto\",\n  disabled: disabledProp,\n  ...props\n}: ButtonProps): React.ReactElement {\n  const isDisabled: boolean = Boolean(loading || disabledProp);\n  const resolvedSize = useResolvedControlSize(size);\n  const resolvedVariant = variant ?? \"default\";\n  const typeValue: React.ButtonHTMLAttributes<HTMLButtonElement>[\"type\"] =\n    render ? undefined : \"button\";\n\n  const defaultProps = {\n    children: (\n      <>\n        {children}\n        {loading && (\n          <Spinner\n            className=\"pointer-events-none absolute\"\n            data-slot=\"button-loading-indicator\"\n          />\n        )}\n      </>\n    ),\n    className: cn(\n      buttonVariants({ size: resolvedSize, variant }),\n      \"siteplane-button-motion\",\n      align === \"start\" && \"justify-start text-left\",\n      align === \"between\" && \"justify-between text-left\",\n      // Die Size-Varianten setzen Hoehe zweimal (h-* und sm:h-*). Wer die Hoehe\n      // freigibt, muss beide loesen - sonst greift ab sm wieder die Fixhoehe.\n      // Das py ersetzt die implizite Zentrierluft der Fixhoehe: h-9 = Zeile\n      // ~20px + 2x7px + Border, sm:h-8 = Zeile + 2x5px + Border. Einzeilige\n      // Buttons behalten so ihre Hoehe, mehrzeilige atmen oben und unten.\n      multiline &&\n        \"h-auto whitespace-normal py-[calc(--spacing(2)-1px)] sm:h-auto sm:py-[calc(--spacing(1.5)-1px)]\",\n      bleed &&\n        \"h-auto self-stretch rounded-none py-[calc(--spacing(2)-1px)] before:rounded-none focus-visible:ring-inset focus-visible:ring-offset-0 sm:h-auto sm:py-[calc(--spacing(1.5)-1px)]\",\n      // Nur die transparente Variante braucht den gedaempften Flaechen-Hover.\n      // Bei gefuellten Varianten wuerde er die Variantenfarbe ueberschreiben\n      // und den Button beim Hovern ausbleichen.\n      surface &&\n        resolvedVariant === \"ghost\" &&\n        \"hover:bg-accent/40 data-pressed:bg-accent/40\",\n      width === \"fit\" && \"w-fit\",\n      width === \"full\" && \"w-full\",\n      className,\n    ),\n    \"aria-disabled\": loading || undefined,\n    \"data-loading\": loading ? \"\" : undefined,\n    \"data-scale\": scale ? undefined : \"false\",\n    \"data-slot\": \"button\",\n    \"data-surface\": surface ? \"\" : undefined,\n    \"data-variant\": resolvedVariant,\n    disabled: isDisabled,\n    type: typeValue,\n  };\n\n  return useRender({\n    defaultTagName: \"button\",\n    props: mergeProps<\"button\">(defaultProps, props),\n    render,\n  });\n}\n"
    }
  ],
  "categories": [
    "actions"
  ],
  "docs": "# Button\n\n> Siteplane UI release `0.1.1`.\n\n## Public Purpose\n\nButton is the standard control for visible actions. Use a semantic link when\nnavigation is the primary behavior.\n\n## Import\n\n`import { Button } from \"@/components/ui/button\";`\n\n`import { ButtonWithIcon, IconButton, LoadingButton } from \"@/components/ui/button-recipes\";`\n\n## Public API\n\n- `variant` accepts `\"default\"`, `\"secondary\"`, `\"outline\"`,\n  `\"ghost\"`, `\"destructive\"`, `\"destructive-outline\"`, `\"link\"`, and\n  `\"success\"`.\n- `size` accepts text sizes from `\"xs\"` through `\"xl\"` and the matching\n  `\"icon-*\"` sizes. Without a local size, Button inherits\n  `SiteplaneUIProvider controlSize`.\n- `size=\"xl\"` is the opt-in customer-facing CTA format: 56px high with an\n  18px/24px label. Combine it with `width=\"full\"` for a container-filling CTA\n  and with a surrounding `SiteplaneUIProvider radius=\"sm\"` for the global\n  8px main corner; size itself never overrides radius.\n- `render={<Link href=\"…\" />}` changes the underlying element while retaining\n  Button styling and omits the invalid button `type` attribute.\n- `loading` disables the control, sets busy semantics, and displays the native\n  loading indicator.\n- `scale={false}` disables both hover and press scaling while preserving color,\n  shadow, focus, loading, and disabled states. Use it when a trigger sits inside\n  a larger custom element that must not move.\n- `align`, `width`, and `multiline` control content layout.\n- `bleed` makes the Button form a flush container edge. `surface` is for\n  full-row or full-card actions and disables scale automatically.\n- `IconButton` requires an accessible `label`; `ButtonWithIcon` and\n  `LoadingButton` are shared recipes, not new primitives.\n\n## Public Motion\n\n- Default hover and press states use the central Button scale, duration, shadow,\n  and reduced-motion tokens.\n- `scale={false}` removes hover and press transforms only.\n- `surface` avoids scale for full-area actions.\n- Loading recipes keep their idle width and crossfade between label and pending\n  content without a second nested animation.\n\n## Public Accessibility\n\n- Use Button for actions and a semantic anchor for navigation through the\n  `render` prop.\n- Every icon-only Button needs an accessible label.\n- Loading and disabled Buttons must remain unavailable and expose their state;\n  do not simulate disabled state with styling alone.\n- Keep visible focus and the coarse-pointer target supplied by the primitive.\n\n## Public Agent Guidance\n\n- Never use a raw HTML button for a visible Siteplane control.\n- Use existing variants, sizes, and recipes instead of copying primitive class\n  strings.\n- Prefer `scale={false}` for embedded triggers that must remain stationary;\n  do not remove the remaining hover, pressed, or focus feedback.\n\n## Public Links\n\n- [Documentation and preview](https://ui.siteplane.io/docs/components/actions/button)\n\n- [Registry source](https://ui.siteplane.io/r/button.json)\n",
  "meta": {
    "siteplane:agentContractVersion": 1,
    "siteplane:docs": "https://ui.siteplane.io/docs/components/actions/button",
    "siteplane:releaseVersion": "0.1.1",
    "siteplane:sourceOwned": true
  },
  "devDependencies": []
}
