@spunto/design-system 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -41,6 +41,53 @@ const nextConfig = { transpilePackages: ["@spunto/design-system"] }
41
41
  - **Primitives** — `Button` (+`buttonVariants`), `Card` (+parts), `Input`, `Textarea`,
42
42
  `Label`, `Badge` (+`badgeVariants`), `Switch`. Dependency-light (cva + clsx +
43
43
  tailwind-merge), no component framework required.
44
+ - **`SpuntoProvider` + `toast()`** — the imperative toast system (see below).
45
+
46
+ ## Toasts — `SpuntoProvider` + `toast()`
47
+
48
+ `SpuntoProvider` is the design system's client umbrella provider. Mount it once
49
+ around your app; today it renders the toast viewport, and it's the extension
50
+ point future overlay features plug into. `toast()` is a sonner-style imperative
51
+ API callable from **anywhere** — event handlers, async code, even outside the
52
+ React tree (it's backed by Base UI's `createToastManager`).
53
+
54
+ In a Next.js App Router app, the root layout stays a Server Component — just
55
+ render the (client) provider around `{children}`:
56
+
57
+ ```tsx
58
+ // app/layout.tsx
59
+ import { SpuntoProvider } from "@spunto/design-system"
60
+ import "@spunto/design-system/styles.css"
61
+
62
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
63
+ return (
64
+ <html lang="en">
65
+ <body>
66
+ <SpuntoProvider toastPosition="top-right">{children}</SpuntoProvider>
67
+ </body>
68
+ </html>
69
+ )
70
+ }
71
+ ```
72
+
73
+ Then fire toasts from anywhere:
74
+
75
+ ```tsx
76
+ import { toast } from "@spunto/design-system"
77
+
78
+ toast.success("Worker ready", { description: "spunto-abc is up" })
79
+ toast.error("Deploy failed", { action: { label: "Retry", onClick: redeploy } })
80
+
81
+ // Persistent (no auto-dismiss) + manual control:
82
+ const id = toast.info("Deploying…", { duration: 0 })
83
+ toast.dismiss(id)
84
+ ```
85
+
86
+ `toast(message, options)` and the `toast.success/error/warning/info` helpers take
87
+ `{ title?, description?, variant?, duration?, action? }` and return the toast id;
88
+ `toast.dismiss(id?)` closes one toast (or all). `SpuntoProvider` accepts
89
+ `toastPosition` (default `"top-right"`), `toastDuration` (default `5000`, `0` =
90
+ persistent) and `toastLimit` (default `3`).
44
91
 
45
92
  ## Peer requirements
46
93
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spunto/design-system",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Spunto's shared design system — warm/flame tokens, color constants, and UI primitives.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -49,11 +49,14 @@
49
49
  "tailwind-merge": "^3.6.0"
50
50
  },
51
51
  "peerDependencies": {
52
+ "@base-ui/react": "^1.6.0",
52
53
  "react": ">=19",
53
54
  "react-dom": ">=19"
54
55
  },
55
56
  "devDependencies": {
57
+ "@base-ui/react": "^1.6.0",
56
58
  "@types/react": "^19",
59
+ "@types/react-dom": "^19",
57
60
  "typescript": "^5"
58
61
  }
59
62
  }
@@ -1,29 +1,52 @@
1
- import * as React from "react"
1
+ import { mergeProps } from "@base-ui/react/merge-props"
2
+ import { useRender } from "@base-ui/react/use-render"
2
3
  import { cva, type VariantProps } from "class-variance-authority"
4
+
3
5
  import { cn } from "../utils"
4
6
 
5
- export const badgeVariants = cva(
6
- "inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs font-medium",
7
+ const badgeVariants = cva(
8
+ "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
7
9
  {
8
10
  variants: {
9
11
  variant: {
10
- default: "border-transparent bg-primary/12 text-primary",
11
- secondary: "border-transparent bg-secondary text-secondary-foreground",
12
- outline: "border-border text-foreground",
13
- success: "border-transparent bg-emerald-500/12 text-emerald-600 dark:text-emerald-400",
14
- warning: "border-transparent bg-amber-500/15 text-amber-600 dark:text-amber-400",
15
- destructive: "border-transparent bg-destructive/12 text-destructive",
16
- muted: "border-transparent bg-muted text-muted-foreground",
12
+ default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
13
+ secondary:
14
+ "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
15
+ destructive:
16
+ "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
17
+ outline:
18
+ "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
19
+ ghost:
20
+ "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
21
+ link: "text-primary underline-offset-4 hover:underline",
17
22
  },
18
23
  },
19
- defaultVariants: { variant: "default" },
20
- },
24
+ defaultVariants: {
25
+ variant: "default",
26
+ },
27
+ }
21
28
  )
22
29
 
23
- export function Badge({
30
+ function Badge({
24
31
  className,
25
- variant,
32
+ variant = "default",
33
+ render,
26
34
  ...props
27
- }: React.HTMLAttributes<HTMLSpanElement> & VariantProps<typeof badgeVariants>) {
28
- return <span className={cn(badgeVariants({ variant }), className)} {...props} />
35
+ }: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
36
+ return useRender({
37
+ defaultTagName: "span",
38
+ props: mergeProps<"span">(
39
+ {
40
+ className: cn(badgeVariants({ variant }), className),
41
+ },
42
+ props
43
+ ),
44
+ render,
45
+ state: {
46
+ slot: "badge",
47
+ variant,
48
+ },
49
+ })
29
50
  }
51
+
52
+ export { Badge, badgeVariants }
@@ -0,0 +1,39 @@
1
+ import { cva } from "class-variance-authority"
2
+
3
+ export const buttonVariants = cva(
4
+ "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/40 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
5
+ {
6
+ variants: {
7
+ variant: {
8
+ default:
9
+ "bg-primary text-primary-foreground hover:bg-primary/85 border-primary/20",
10
+ outline:
11
+ "border-border bg-background hover:bg-accent hover:text-accent-foreground aria-expanded:bg-accent dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
12
+ secondary:
13
+ "bg-secondary text-secondary-foreground hover:bg-secondary/70 border-secondary/50",
14
+ ghost:
15
+ "hover:bg-accent hover:text-accent-foreground aria-expanded:bg-accent dark:hover:bg-muted/50",
16
+ destructive:
17
+ "bg-destructive/10 text-destructive hover:bg-destructive/20 border-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30",
18
+ link: "text-primary underline-offset-4 hover:underline",
19
+ },
20
+ size: {
21
+ default:
22
+ "h-8 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
23
+ xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
24
+ sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
25
+ lg: "h-9 gap-1.5 px-4",
26
+ icon: "size-8",
27
+ "icon-xs":
28
+ "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
29
+ "icon-sm":
30
+ "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
31
+ "icon-lg": "size-9",
32
+ },
33
+ },
34
+ defaultVariants: {
35
+ variant: "default",
36
+ size: "default",
37
+ },
38
+ }
39
+ )
@@ -1,36 +1,24 @@
1
- import * as React from "react"
2
- import { cva, type VariantProps } from "class-variance-authority"
3
- import { cn } from "../utils"
1
+ "use client"
2
+
3
+ import { Button as ButtonPrimitive } from "@base-ui/react/button"
4
+ import { type VariantProps } from "class-variance-authority"
4
5
 
5
- export const buttonVariants = cva(
6
- "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0",
7
- {
8
- variants: {
9
- variant: {
10
- default: "bg-primary text-primary-foreground hover:opacity-90",
11
- secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
12
- outline: "border border-border bg-transparent hover:bg-accent hover:text-accent-foreground",
13
- ghost: "hover:bg-accent hover:text-accent-foreground",
14
- destructive: "bg-destructive text-white hover:opacity-90",
15
- },
16
- size: {
17
- default: "h-9 px-4 py-2",
18
- sm: "h-8 px-3 text-xs",
19
- lg: "h-10 px-6",
20
- icon: "size-9",
21
- },
22
- },
23
- defaultVariants: { variant: "default", size: "default" },
24
- },
25
- )
6
+ import { cn } from "../utils"
7
+ import { buttonVariants } from "./button-variants"
26
8
 
27
- export interface ButtonProps
28
- extends React.ButtonHTMLAttributes<HTMLButtonElement>,
29
- VariantProps<typeof buttonVariants> {}
9
+ function Button({
10
+ className,
11
+ variant = "default",
12
+ size = "default",
13
+ ...props
14
+ }: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
15
+ return (
16
+ <ButtonPrimitive
17
+ data-slot="button"
18
+ className={cn(buttonVariants({ variant, size, className }))}
19
+ {...props}
20
+ />
21
+ )
22
+ }
30
23
 
31
- export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
32
- ({ className, variant, size, ...props }, ref) => (
33
- <button ref={ref} className={cn(buttonVariants({ variant, size }), className)} {...props} />
34
- ),
35
- )
36
- Button.displayName = "Button"
24
+ export { Button, buttonVariants }
@@ -1,26 +1,103 @@
1
1
  import * as React from "react"
2
+
2
3
  import { cn } from "../utils"
3
4
 
4
- export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
5
- return <div className={cn("rounded-xl border border-border bg-card text-card-foreground", className)} {...props} />
5
+ function Card({
6
+ className,
7
+ size = "default",
8
+ ...props
9
+ }: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
10
+ return (
11
+ <div
12
+ data-slot="card"
13
+ data-size={size}
14
+ className={cn(
15
+ "group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
16
+ className
17
+ )}
18
+ {...props}
19
+ />
20
+ )
21
+ }
22
+
23
+ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
24
+ return (
25
+ <div
26
+ data-slot="card-header"
27
+ className={cn(
28
+ "group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
29
+ className
30
+ )}
31
+ {...props}
32
+ />
33
+ )
34
+ }
35
+
36
+ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
37
+ return (
38
+ <div
39
+ data-slot="card-title"
40
+ className={cn(
41
+ "text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
42
+ className
43
+ )}
44
+ {...props}
45
+ />
46
+ )
6
47
  }
7
48
 
8
- export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
9
- return <div className={cn("flex flex-col gap-1.5 p-5", className)} {...props} />
49
+ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
50
+ return (
51
+ <div
52
+ data-slot="card-description"
53
+ className={cn("text-sm text-muted-foreground", className)}
54
+ {...props}
55
+ />
56
+ )
10
57
  }
11
58
 
12
- export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
13
- return <h3 className={cn("font-semibold leading-none tracking-tight", className)} {...props} />
59
+ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
60
+ return (
61
+ <div
62
+ data-slot="card-action"
63
+ className={cn(
64
+ "col-start-2 row-span-2 row-start-1 self-start justify-self-end",
65
+ className
66
+ )}
67
+ {...props}
68
+ />
69
+ )
14
70
  }
15
71
 
16
- export function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
17
- return <p className={cn("text-sm text-muted-foreground", className)} {...props} />
72
+ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
73
+ return (
74
+ <div
75
+ data-slot="card-content"
76
+ className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
77
+ {...props}
78
+ />
79
+ )
18
80
  }
19
81
 
20
- export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
21
- return <div className={cn("p-5 pt-0", className)} {...props} />
82
+ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
83
+ return (
84
+ <div
85
+ data-slot="card-footer"
86
+ className={cn(
87
+ "flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
88
+ className
89
+ )}
90
+ {...props}
91
+ />
92
+ )
22
93
  }
23
94
 
24
- export function CardFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
25
- return <div className={cn("flex items-center p-5 pt-0", className)} {...props} />
95
+ export {
96
+ Card,
97
+ CardHeader,
98
+ CardFooter,
99
+ CardTitle,
100
+ CardAction,
101
+ CardDescription,
102
+ CardContent,
26
103
  }
@@ -1,16 +1,20 @@
1
1
  import * as React from "react"
2
+ import { Input as InputPrimitive } from "@base-ui/react/input"
3
+
2
4
  import { cn } from "../utils"
3
5
 
4
- export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
5
- ({ className, ...props }, ref) => (
6
- <input
7
- ref={ref}
6
+ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
7
+ return (
8
+ <InputPrimitive
9
+ type={type}
10
+ data-slot="input"
8
11
  className={cn(
9
- "flex h-9 w-full rounded-lg border border-input bg-background px-3 py-1 text-sm shadow-none transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
10
- className,
12
+ "h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
13
+ className
11
14
  )}
12
15
  {...props}
13
16
  />
14
- ),
15
- )
16
- Input.displayName = "Input"
17
+ )
18
+ }
19
+
20
+ export { Input }
@@ -1,9 +1,20 @@
1
+ "use client"
2
+
1
3
  import * as React from "react"
4
+
2
5
  import { cn } from "../utils"
3
6
 
4
- export const Label = React.forwardRef<HTMLLabelElement, React.LabelHTMLAttributes<HTMLLabelElement>>(
5
- ({ className, ...props }, ref) => (
6
- <label ref={ref} className={cn("text-sm font-medium leading-none", className)} {...props} />
7
- ),
8
- )
9
- Label.displayName = "Label"
7
+ function Label({ className, ...props }: React.ComponentProps<"label">) {
8
+ return (
9
+ <label
10
+ data-slot="label"
11
+ className={cn(
12
+ "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
13
+ className
14
+ )}
15
+ {...props}
16
+ />
17
+ )
18
+ }
19
+
20
+ export { Label }
@@ -0,0 +1,77 @@
1
+ "use client"
2
+
3
+ import type { ReactNode } from "react"
4
+ import { Toast as ToastPrimitive } from "@base-ui/react/toast"
5
+
6
+ import { ToastList, toastManager, type ToastSwipeDirection } from "./toast"
7
+
8
+ export type ToastPosition =
9
+ | "top-left"
10
+ | "top-center"
11
+ | "top-right"
12
+ | "bottom-left"
13
+ | "bottom-center"
14
+ | "bottom-right"
15
+
16
+ export interface SpuntoProviderProps {
17
+ /** App tree. */
18
+ children?: ReactNode
19
+ /**
20
+ * Where the toast stack docks.
21
+ * @default "top-right"
22
+ */
23
+ toastPosition?: ToastPosition
24
+ /**
25
+ * Default auto-dismiss delay (ms) for toasts that don't set their own.
26
+ * `0` keeps toasts until dismissed.
27
+ * @default 5000
28
+ */
29
+ toastDuration?: number
30
+ /**
31
+ * Maximum number of toasts stacked at once. Older toasts collapse behind the
32
+ * newest and animate out past this count.
33
+ * @default 3
34
+ */
35
+ toastLimit?: number
36
+ }
37
+
38
+ // Toasts swipe toward the nearest screen edge for the current position.
39
+ const SWIPE_DIRECTIONS: Record<ToastPosition, ToastSwipeDirection[]> = {
40
+ "top-left": ["up", "left"],
41
+ "top-center": ["up"],
42
+ "top-right": ["up", "right"],
43
+ "bottom-left": ["down", "left"],
44
+ "bottom-center": ["down"],
45
+ "bottom-right": ["down", "right"],
46
+ }
47
+
48
+ /**
49
+ * Client-side umbrella provider for the Spunto design system. Wrap your app once
50
+ * (in a Next.js `app/layout.tsx`, it's a client component — render it around
51
+ * `{children}` inside the server layout, no `"use client"` needed on the layout
52
+ * itself). Today it mounts the toast viewport; it's the seam future overlay
53
+ * features (dialogs, tooltips, theme config) plug into without a breaking change.
54
+ *
55
+ * ```tsx
56
+ * <SpuntoProvider toastPosition="bottom-right">
57
+ * {children}
58
+ * </SpuntoProvider>
59
+ * ```
60
+ */
61
+ export function SpuntoProvider({
62
+ children,
63
+ toastPosition = "top-right",
64
+ toastDuration = 5000,
65
+ toastLimit = 3,
66
+ }: SpuntoProviderProps) {
67
+ return (
68
+ <ToastPrimitive.Provider toastManager={toastManager} timeout={toastDuration} limit={toastLimit}>
69
+ {children}
70
+ <ToastPrimitive.Portal>
71
+ <ToastPrimitive.Viewport data-slot="toast-viewport" data-position={toastPosition}>
72
+ <ToastList swipeDirection={SWIPE_DIRECTIONS[toastPosition]} />
73
+ </ToastPrimitive.Viewport>
74
+ </ToastPrimitive.Portal>
75
+ </ToastPrimitive.Provider>
76
+ )
77
+ }
@@ -1,41 +1,32 @@
1
1
  "use client"
2
2
 
3
- import * as React from "react"
3
+ import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
4
+
4
5
  import { cn } from "../utils"
5
6
 
6
- export function Switch({
7
- checked,
8
- onCheckedChange,
7
+ function Switch({
9
8
  className,
10
- id,
11
- disabled,
12
- }: {
13
- checked: boolean
14
- onCheckedChange: (v: boolean) => void
15
- className?: string
16
- id?: string
17
- disabled?: boolean
9
+ size = "default",
10
+ ...props
11
+ }: SwitchPrimitive.Root.Props & {
12
+ size?: "sm" | "default"
18
13
  }) {
19
14
  return (
20
- <button
21
- type="button"
22
- role="switch"
23
- id={id}
24
- aria-checked={checked}
25
- disabled={disabled}
26
- onClick={() => onCheckedChange(!checked)}
15
+ <SwitchPrimitive.Root
16
+ data-slot="switch"
17
+ data-size={size}
27
18
  className={cn(
28
- "relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50",
29
- checked ? "bg-primary" : "bg-input",
30
- className,
19
+ "peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
20
+ className
31
21
  )}
22
+ {...props}
32
23
  >
33
- <span
34
- className={cn(
35
- "inline-block size-4 rounded-full bg-white shadow-sm transition-transform",
36
- checked ? "translate-x-4" : "translate-x-0.5",
37
- )}
24
+ <SwitchPrimitive.Thumb
25
+ data-slot="switch-thumb"
26
+ className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
38
27
  />
39
- </button>
28
+ </SwitchPrimitive.Root>
40
29
  )
41
30
  }
31
+
32
+ export { Switch }
@@ -1,16 +1,18 @@
1
1
  import * as React from "react"
2
+
2
3
  import { cn } from "../utils"
3
4
 
4
- export const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(
5
- ({ className, ...props }, ref) => (
5
+ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
6
+ return (
6
7
  <textarea
7
- ref={ref}
8
+ data-slot="textarea"
8
9
  className={cn(
9
- "flex min-h-[72px] w-full rounded-lg border border-input bg-background px-3 py-2 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
10
- className,
10
+ "flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
11
+ className
11
12
  )}
12
13
  {...props}
13
14
  />
14
- ),
15
- )
16
- Textarea.displayName = "Textarea"
15
+ )
16
+ }
17
+
18
+ export { Textarea }
@@ -0,0 +1,199 @@
1
+ "use client"
2
+
3
+ import type { ReactNode } from "react"
4
+ import { Toast as ToastPrimitive } from "@base-ui/react/toast"
5
+
6
+ import { cn } from "../utils"
7
+
8
+ export type ToastVariant = "success" | "error" | "warning" | "info"
9
+
10
+ /** Optional call-to-action rendered as a button inside the toast. */
11
+ export interface ToastAction {
12
+ label: string
13
+ onClick: () => void
14
+ }
15
+
16
+ export interface ToastOptions {
17
+ /** Overrides the first positional argument as the toast's heading. */
18
+ title?: ReactNode
19
+ /** Secondary line under the title. */
20
+ description?: ReactNode
21
+ /** Colour + icon. Defaults to a neutral toast when omitted. */
22
+ variant?: ToastVariant
23
+ /**
24
+ * Auto-dismiss delay in ms. `0` keeps the toast until dismissed.
25
+ * Falls back to the `SpuntoProvider` `toastDuration` when omitted.
26
+ */
27
+ duration?: number
28
+ /** Optional action button. Clicking it also dismisses the toast. */
29
+ action?: ToastAction
30
+ }
31
+
32
+ /** Payload we stash on each Base UI toast so the renderer can style it. */
33
+ export interface SpuntoToastData {
34
+ variant?: ToastVariant
35
+ action?: ToastAction
36
+ }
37
+
38
+ /**
39
+ * Singleton manager shared by the imperative `toast()` API and the
40
+ * `SpuntoProvider` that renders it. Created with Base UI's `createToastManager`
41
+ * so `toast()` is callable from anywhere — event handlers, async code, even
42
+ * outside the React tree.
43
+ */
44
+ export const toastManager = ToastPrimitive.createToastManager<SpuntoToastData>()
45
+
46
+ function emit(message: ReactNode, options: ToastOptions = {}, variant?: ToastVariant): string {
47
+ const resolved = variant ?? options.variant
48
+ return toastManager.add({
49
+ title: options.title ?? message,
50
+ description: options.description,
51
+ // `undefined` lets the provider's default timeout apply.
52
+ timeout: options.duration,
53
+ type: resolved,
54
+ data: { variant: resolved, action: options.action },
55
+ })
56
+ }
57
+
58
+ export interface ToastFn {
59
+ (message: ReactNode, options?: ToastOptions): string
60
+ success: (message: ReactNode, options?: ToastOptions) => string
61
+ error: (message: ReactNode, options?: ToastOptions) => string
62
+ warning: (message: ReactNode, options?: ToastOptions) => string
63
+ info: (message: ReactNode, options?: ToastOptions) => string
64
+ /** Dismiss a specific toast by id, or all toasts when called without one. */
65
+ dismiss: (id?: string) => void
66
+ }
67
+
68
+ /**
69
+ * Imperative, sonner-style toast API. Import and call from anywhere.
70
+ *
71
+ * ```ts
72
+ * toast.success("Worker ready", { description: "spunto-abc is up" })
73
+ * const id = toast("Deploying…", { duration: 0 })
74
+ * toast.dismiss(id)
75
+ * ```
76
+ */
77
+ export const toast: ToastFn = Object.assign(
78
+ (message: ReactNode, options?: ToastOptions) => emit(message, options),
79
+ {
80
+ success: (message: ReactNode, options?: ToastOptions) => emit(message, options, "success"),
81
+ error: (message: ReactNode, options?: ToastOptions) => emit(message, options, "error"),
82
+ warning: (message: ReactNode, options?: ToastOptions) => emit(message, options, "warning"),
83
+ info: (message: ReactNode, options?: ToastOptions) => emit(message, options, "info"),
84
+ dismiss: (id?: string) => toastManager.close(id),
85
+ }
86
+ )
87
+
88
+ /** Per-variant accent (icon + left border). Derived from the status tokens. */
89
+ const VARIANT_ACCENT: Record<ToastVariant, string> = {
90
+ success: "text-success border-l-success",
91
+ error: "text-destructive border-l-destructive",
92
+ warning: "text-warning border-l-warning",
93
+ info: "text-info border-l-info",
94
+ }
95
+
96
+ function VariantIcon({ variant }: { variant?: ToastVariant }) {
97
+ const common = { width: 18, height: 18, viewBox: "0 0 24 24", fill: "none", "aria-hidden": true } as const
98
+ switch (variant) {
99
+ case "success":
100
+ return (
101
+ <svg {...common} stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
102
+ <circle cx="12" cy="12" r="9" />
103
+ <path d="m8.5 12 2.5 2.5 4.5-5" />
104
+ </svg>
105
+ )
106
+ case "error":
107
+ return (
108
+ <svg {...common} stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
109
+ <circle cx="12" cy="12" r="9" />
110
+ <path d="M15 9l-6 6M9 9l6 6" />
111
+ </svg>
112
+ )
113
+ case "warning":
114
+ return (
115
+ <svg {...common} stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
116
+ <path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z" />
117
+ <path d="M12 9v4M12 17h.01" />
118
+ </svg>
119
+ )
120
+ case "info":
121
+ return (
122
+ <svg {...common} stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
123
+ <circle cx="12" cy="12" r="9" />
124
+ <path d="M12 11v5M12 8h.01" />
125
+ </svg>
126
+ )
127
+ default:
128
+ return (
129
+ <svg {...common} stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
130
+ <path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
131
+ <path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
132
+ </svg>
133
+ )
134
+ }
135
+ }
136
+
137
+ function CloseIcon() {
138
+ return (
139
+ <svg width={14} height={14} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
140
+ <path d="M18 6 6 18M6 6l12 12" />
141
+ </svg>
142
+ )
143
+ }
144
+
145
+ /**
146
+ * Renders the live toast stack. Consumes the manager via `useToastManager`, so
147
+ * it must live inside a `Toast.Provider` — `SpuntoProvider` handles that.
148
+ * @internal
149
+ */
150
+ export type ToastSwipeDirection = "up" | "down" | "left" | "right"
151
+
152
+ export function ToastList({ swipeDirection }: { swipeDirection?: ToastSwipeDirection[] }) {
153
+ const { toasts } = ToastPrimitive.useToastManager<SpuntoToastData>()
154
+
155
+ return toasts.map((item) => {
156
+ const variant = item.data?.variant
157
+ const action = item.data?.action
158
+ return (
159
+ <ToastPrimitive.Root
160
+ key={item.id}
161
+ toast={item}
162
+ swipeDirection={swipeDirection}
163
+ data-slot="toast"
164
+ className={cn(
165
+ "pointer-events-auto rounded-lg border border-border border-l-4 bg-popover text-popover-foreground shadow-lg shadow-black/[0.06] outline-none",
166
+ "focus-visible:ring-2 focus-visible:ring-ring/50",
167
+ variant ? VARIANT_ACCENT[variant] : "border-l-muted-foreground/40"
168
+ )}
169
+ >
170
+ <ToastPrimitive.Content className="flex h-full items-start gap-3 p-3.5 transition-opacity duration-200 data-behind:opacity-0 data-expanded:opacity-100">
171
+ <span className={cn("mt-px shrink-0", variant ? VARIANT_ACCENT[variant] : "text-muted-foreground")}>
172
+ <VariantIcon variant={variant} />
173
+ </span>
174
+ <div className="flex min-w-0 flex-1 flex-col gap-1">
175
+ <ToastPrimitive.Title className="text-sm leading-snug font-semibold text-foreground" />
176
+ <ToastPrimitive.Description className="text-sm leading-snug text-muted-foreground" />
177
+ {action && (
178
+ <ToastPrimitive.Action
179
+ className="mt-1.5 inline-flex h-7 w-fit items-center rounded-md border border-border bg-background px-2.5 text-xs font-medium text-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring/50 outline-none"
180
+ onClick={() => {
181
+ action.onClick()
182
+ toastManager.close(item.id)
183
+ }}
184
+ >
185
+ {action.label}
186
+ </ToastPrimitive.Action>
187
+ )}
188
+ </div>
189
+ <ToastPrimitive.Close
190
+ aria-label="Fermer"
191
+ className="-mt-1 -mr-1 flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/70 transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 outline-none"
192
+ >
193
+ <CloseIcon />
194
+ </ToastPrimitive.Close>
195
+ </ToastPrimitive.Content>
196
+ </ToastPrimitive.Root>
197
+ )
198
+ })
199
+ }
package/src/index.ts CHANGED
@@ -4,10 +4,20 @@
4
4
  export { cn } from "./utils"
5
5
  export * from "./colors"
6
6
 
7
- export { Button, buttonVariants, type ButtonProps } from "./components/button"
8
- export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "./components/card"
7
+ export { Button } from "./components/button"
8
+ // `buttonVariants` is exported from its own directive-free module (not through the
9
+ // "use client" button.tsx) so it stays callable from React Server Components.
10
+ export { buttonVariants } from "./components/button-variants"
11
+ export { Card, CardHeader, CardTitle, CardDescription, CardAction, CardContent, CardFooter } from "./components/card"
9
12
  export { Input } from "./components/input"
10
13
  export { Textarea } from "./components/textarea"
11
14
  export { Label } from "./components/label"
12
15
  export { Badge, badgeVariants } from "./components/badge"
13
16
  export { Switch } from "./components/switch"
17
+
18
+ // Toasts — imperative `toast()` API + the umbrella client provider that renders
19
+ // them. `SpuntoProvider` is the extension point for future overlay features.
20
+ export { SpuntoProvider } from "./components/spunto-provider"
21
+ export type { SpuntoProviderProps, ToastPosition } from "./components/spunto-provider"
22
+ export { toast } from "./components/toast"
23
+ export type { ToastFn, ToastOptions, ToastVariant, ToastAction } from "./components/toast"
package/styles.css CHANGED
@@ -33,6 +33,13 @@
33
33
  --color-input: var(--input);
34
34
  --color-border: var(--border);
35
35
  --color-destructive: var(--destructive);
36
+ /* Status semantics — used by feedback surfaces (Toast…) */
37
+ --color-success: var(--success);
38
+ --color-success-foreground: var(--success-foreground);
39
+ --color-warning: var(--warning);
40
+ --color-warning-foreground: var(--warning-foreground);
41
+ --color-info: var(--info);
42
+ --color-info-foreground: var(--info-foreground);
36
43
  --color-accent-foreground: var(--accent-foreground);
37
44
  --color-accent: var(--accent);
38
45
  --color-muted-foreground: var(--muted-foreground);
@@ -77,6 +84,13 @@
77
84
  --accent: oklch(0.935 0.024 72);
78
85
  --accent-foreground: oklch(0.34 0.045 52);
79
86
  --destructive: oklch(0.577 0.245 27.325);
87
+ /* Status semantics — warm-leaning green / amber / blue, tuned for the cream bg */
88
+ --success: oklch(0.62 0.145 152);
89
+ --success-foreground: oklch(0.99 0.012 86);
90
+ --warning: oklch(0.74 0.16 68);
91
+ --warning-foreground: oklch(0.24 0.05 60);
92
+ --info: oklch(0.58 0.13 245);
93
+ --info-foreground: oklch(0.99 0.012 86);
80
94
  --border: oklch(0.89 0.016 76);
81
95
  --input: oklch(0.89 0.016 76);
82
96
  --ring: oklch(0.64 0.205 41);
@@ -122,6 +136,13 @@
122
136
  --accent: oklch(0.28 0.018 54);
123
137
  --accent-foreground: oklch(0.95 0.009 78);
124
138
  --destructive: oklch(0.704 0.191 22.216);
139
+ /* Status semantics — lifted for the warm charcoal bg */
140
+ --success: oklch(0.72 0.15 154);
141
+ --success-foreground: oklch(0.16 0.012 52);
142
+ --warning: oklch(0.80 0.155 72);
143
+ --warning-foreground: oklch(0.16 0.012 52);
144
+ --info: oklch(0.68 0.13 240);
145
+ --info-foreground: oklch(0.16 0.012 52);
125
146
  --border: oklch(0.92 0.03 70 / 11%);
126
147
  --input: oklch(0.92 0.03 70 / 14%);
127
148
  --ring: oklch(0.70 0.20 43);
@@ -184,3 +205,133 @@
184
205
  linear-gradient(to right, oklch(0.92 0.04 70 / 0.04) 1px, transparent 1px),
185
206
  linear-gradient(to bottom, oklch(0.92 0.04 70 / 0.04) 1px, transparent 1px);
186
207
  }
208
+
209
+ /* ─── Toast — stacking + enter/exit motion (mounted by SpuntoProvider) ─────
210
+ *
211
+ * The look (colors, spacing, typography) lives on the components as Tailwind
212
+ * utilities; this block owns only the parts Tailwind can't express cleanly —
213
+ * the position-aware stacking math driven by the CSS vars Base UI sets on each
214
+ * toast (`--toast-index`, `--toast-offset-y`, `--toast-frontmost-height`,
215
+ * `--toast-swipe-movement-*`). `data-position` on the viewport flips the whole
216
+ * system between the top and bottom anchors. Adapted from the Base UI toast
217
+ * reference example. */
218
+ [data-slot="toast-viewport"] {
219
+ --toast-gap: 0.75rem;
220
+ --toast-peek: 0.6rem;
221
+ position: fixed;
222
+ z-index: 60;
223
+ display: flex;
224
+ width: min(24rem, calc(100vw - 2rem));
225
+ }
226
+ [data-slot="toast-viewport"][data-position$="right"] { right: 1.25rem; left: auto; }
227
+ [data-slot="toast-viewport"][data-position$="left"] { left: 1.25rem; right: auto; }
228
+ [data-slot="toast-viewport"][data-position$="center"] {
229
+ left: 50%;
230
+ right: auto;
231
+ transform: translateX(-50%);
232
+ }
233
+ [data-slot="toast-viewport"][data-position^="top"] { top: 1.25rem; bottom: auto; }
234
+ [data-slot="toast-viewport"][data-position^="bottom"] { bottom: 1.25rem; top: auto; }
235
+
236
+ [data-slot="toast"] {
237
+ --scale: max(0, 1 - (var(--toast-index) * 0.1));
238
+ --shrink: calc(1 - var(--scale));
239
+ --height: var(--toast-frontmost-height, var(--toast-height));
240
+ position: absolute;
241
+ right: 0;
242
+ left: auto;
243
+ width: 100%;
244
+ box-sizing: border-box;
245
+ z-index: calc(1000 - var(--toast-index));
246
+ height: var(--height);
247
+ transition:
248
+ transform 0.5s cubic-bezier(0.22, 1, 0.36, 1),
249
+ opacity 0.4s ease,
250
+ height 0.2s ease;
251
+ }
252
+ /* Invisible bridge so the pointer can travel across the gap between toasts
253
+ without the stack collapsing. */
254
+ [data-slot="toast"]::after {
255
+ content: "";
256
+ position: absolute;
257
+ left: 0;
258
+ width: 100%;
259
+ height: calc(var(--toast-gap) + 1px);
260
+ }
261
+ [data-slot="toast"][data-limited],
262
+ [data-slot="toast"][data-starting-style],
263
+ [data-slot="toast"][data-ending-style] { opacity: 0; }
264
+
265
+ /* Bottom-anchored (default) — newest at the bottom, older peek upward. */
266
+ [data-position^="bottom"] [data-slot="toast"] {
267
+ bottom: 0;
268
+ transform-origin: bottom center;
269
+ --offset-y: calc(
270
+ var(--toast-offset-y) * -1 + (var(--toast-index) * var(--toast-gap) * -1) +
271
+ var(--toast-swipe-movement-y)
272
+ );
273
+ transform:
274
+ translateX(var(--toast-swipe-movement-x))
275
+ translateY(
276
+ calc(
277
+ var(--toast-swipe-movement-y) - (var(--toast-index) * var(--toast-peek)) -
278
+ (var(--shrink) * var(--height))
279
+ )
280
+ )
281
+ scale(var(--scale));
282
+ }
283
+ [data-position^="bottom"] [data-slot="toast"]::after { top: 100%; }
284
+ [data-position^="bottom"] [data-slot="toast"][data-expanded] {
285
+ transform: translateX(var(--toast-swipe-movement-x)) translateY(var(--offset-y));
286
+ height: var(--toast-height);
287
+ }
288
+ [data-position^="bottom"] [data-slot="toast"][data-starting-style],
289
+ [data-position^="bottom"] [data-slot="toast"][data-ending-style]:not([data-swipe-direction]) {
290
+ transform: translateY(150%);
291
+ }
292
+
293
+ /* Top-anchored — newest at the top, older peek downward. */
294
+ [data-position^="top"] [data-slot="toast"] {
295
+ top: 0;
296
+ transform-origin: top center;
297
+ --offset-y: calc(
298
+ var(--toast-offset-y) + (var(--toast-index) * var(--toast-gap)) +
299
+ var(--toast-swipe-movement-y)
300
+ );
301
+ transform:
302
+ translateX(var(--toast-swipe-movement-x))
303
+ translateY(
304
+ calc(
305
+ var(--toast-swipe-movement-y) + (var(--toast-index) * var(--toast-peek)) +
306
+ (var(--shrink) * var(--height))
307
+ )
308
+ )
309
+ scale(var(--scale));
310
+ }
311
+ [data-position^="top"] [data-slot="toast"]::after { bottom: 100%; }
312
+ [data-position^="top"] [data-slot="toast"][data-expanded] {
313
+ transform: translateX(var(--toast-swipe-movement-x)) translateY(var(--offset-y));
314
+ height: var(--toast-height);
315
+ }
316
+ [data-position^="top"] [data-slot="toast"][data-starting-style],
317
+ [data-position^="top"] [data-slot="toast"][data-ending-style]:not([data-swipe-direction]) {
318
+ transform: translateY(-150%);
319
+ }
320
+
321
+ /* Swipe-to-dismiss exits — fly out toward the swiped edge (both anchors). */
322
+ [data-slot="toast"][data-ending-style][data-swipe-direction="up"] {
323
+ transform: translateY(calc(var(--toast-swipe-movement-y) - 150%));
324
+ }
325
+ [data-slot="toast"][data-ending-style][data-swipe-direction="down"] {
326
+ transform: translateY(calc(var(--toast-swipe-movement-y) + 150%));
327
+ }
328
+ [data-slot="toast"][data-ending-style][data-swipe-direction="left"] {
329
+ transform: translateX(calc(var(--toast-swipe-movement-x) - 150%)) translateY(var(--offset-y));
330
+ }
331
+ [data-slot="toast"][data-ending-style][data-swipe-direction="right"] {
332
+ transform: translateX(calc(var(--toast-swipe-movement-x) + 150%)) translateY(var(--offset-y));
333
+ }
334
+
335
+ @media (prefers-reduced-motion: reduce) {
336
+ [data-slot="toast"] { transition-duration: 0.01ms; }
337
+ }