diff --git a/apps/compliance-portal/src/App.tsx b/apps/compliance-portal/src/App.tsx index 4d60faf52..49d48c384 100644 --- a/apps/compliance-portal/src/App.tsx +++ b/apps/compliance-portal/src/App.tsx @@ -27,11 +27,11 @@ import { router } from "#/routes"; export function App() { return ( - - + + - - - + + + ); } diff --git a/packages/ui/src/v2/Toaster/Toaster.stories.tsx b/packages/ui/src/v2/Toaster/Toaster.stories.tsx new file mode 100644 index 000000000..e7267e82e --- /dev/null +++ b/packages/ui/src/v2/Toaster/Toaster.stories.tsx @@ -0,0 +1,94 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { Toast } from "@base-ui/react/toast"; + +import { Button } from "../Button/Button"; + +import { Toaster } from "./Toaster"; + +export default { + title: "v2/Toaster", + component: Toaster, +}; + +const types = ["neutral", "success", "error", "warning", "info"] as const; + +const titles: Record<(typeof types)[number], string> = { + neutral: "Saved", + success: "Access requested", + error: "Something went wrong", + warning: "Session expiring", + info: "New update available", +}; + +const descriptions: Record<(typeof types)[number], string> = { + neutral: "Your changes have been saved.", + success: "We'll email you once it's approved.", + error: "Please try again in a moment.", + warning: "Your session expires in 5 minutes.", + info: "A subprocessor list was published.", +}; + +function ToasterDemo({ withDescription = false }: { withDescription?: boolean }) { + const toast = Toast.useToastManager(); + + return ( +
+ {types.map(type => ( + + ))} + +
+ ); +} + +// Title-only toasts (the common case — most app mutations toast a single line). +// The Toaster reads Base UI's toast manager, so it must be wrapped in a +// `Toast.Provider` (mounted once at the app root in real usage). +export function Default() { + return ( + + + + ); +} + +// Toasts carrying a supporting description line under the title. +export function WithDescription() { + return ( + + + + ); +} diff --git a/packages/ui/src/v2/Toaster/Toaster.tsx b/packages/ui/src/v2/Toaster/Toaster.tsx index 85d408a64..8ed1b1a4b 100644 --- a/packages/ui/src/v2/Toaster/Toaster.tsx +++ b/packages/ui/src/v2/Toaster/Toaster.tsx @@ -19,18 +19,37 @@ // SOFTWARE. import { Toast } from "@base-ui/react/toast"; -import { XIcon } from "@phosphor-icons/react"; +import { + CheckCircleIcon, + InfoIcon, + WarningCircleIcon, + WarningIcon, + XIcon, +} from "@phosphor-icons/react"; +import type { ReactNode } from "react"; import { toaster } from "./variants"; -type ToastType = "success" | "error" | "neutral"; +type ToastType = "neutral" | "success" | "error" | "warning" | "info"; function resolveType(type: string | undefined): ToastType { - return type === "success" || type === "error" ? type : "neutral"; + if (type === "success" || type === "error" || type === "warning" || type === "info") { + return type; + } + return "neutral"; } -// Renders the active toasts from the Base UI toast manager. Mount once at the -// app root, inside a ``. +const typeIcons: Record = { + neutral: , + success: , + error: , + warning: , + info: , +}; + +// Styled Base UI toast viewport. Mount once at the app root inside a +// `Toast.Provider`; queue toasts through `Toast.useToastManager()`. See +// contrib/claude/ui.md. export function Toaster() { const { toasts } = Toast.useToastManager(); const slots = toaster(); @@ -39,15 +58,21 @@ export function Toaster() { {toasts.map((toast) => { - const variant = { type: resolveType(toast.type) }; + const type = resolveType(toast.type); + const typed = toaster({ type }); return ( - -
- - + + + {typeIcons[type]} + +
+ + {toast.description != null && ( + + )}
- +
diff --git a/packages/ui/src/v2/Toaster/variants.ts b/packages/ui/src/v2/Toaster/variants.ts index 51d4f1bd5..51e864732 100644 --- a/packages/ui/src/v2/Toaster/variants.ts +++ b/packages/ui/src/v2/Toaster/variants.ts @@ -20,30 +20,47 @@ import { tv } from "tailwind-variants/lite"; -// Toaster (Radix "Toast" over Base UI's Toast). A bottom-right stack of -// dismissible notifications, colored by type. - +// Styled Base UI toast. Each toast is an elevated card that reuses Callout's +// `surface` hue language (tinted background + hue border, icon and body in the +// hue text step, title one step higher for contrast) so toasts and callouts +// read as one family. Meaning is carried by the icon too, not color alone. export const toaster = tv({ slots: { - viewport: "fixed right-4 bottom-4 z-50 flex w-full max-w-sm flex-col gap-2 outline-none", - toast: [ - "pointer-events-auto flex items-start gap-3 rounded-3 border border-sand-a6 bg-sand-1 p-3 shadow-3", - "transition-all duration-200 ease-out", - "data-starting-style:translate-x-4 data-starting-style:opacity-0", - "data-ending-style:translate-x-4 data-ending-style:opacity-0", + viewport: [ + "fixed bottom-0 right-0 z-60 flex w-[380px] max-w-[calc(100vw-2rem)] flex-col gap-2 p-4", + "outline-none", ], + root: [ + "flex items-start gap-3 rounded-4 border p-4 shadow-4", + "transition-all duration-150", + "data-starting-style:translate-y-2 data-starting-style:opacity-0", + "data-ending-style:translate-y-2 data-ending-style:opacity-0", + ], + // Icon and description inherit the root's hue text step (currentColor). + icon: "mt-px shrink-0 [&_svg]:size-5", content: "flex min-w-0 flex-1 flex-col gap-1", - title: "text-2 font-medium text-sand-12", - description: "text-2 break-words text-sand-11", - close: "flex size-5 shrink-0 items-center justify-center rounded-1 text-sand-a10 outline-none transition-colors hover:bg-sand-3 hover:text-sand-12 [&_svg]:size-4", + title: "text-2 font-medium", + description: "text-1", + close: "-mr-1 -mt-1 shrink-0 rounded-2 p-1 opacity-70 transition-opacity hover:opacity-100 [&_svg]:size-4", }, variants: { + // Mirrors Callout's surface tokens: bg step 2, border step 6, text step 11, + // with the title bumped to step 12. type: { - success: { toast: "border-green-6 bg-green-2", title: "text-green-12" }, - error: { toast: "border-red-6 bg-red-2", title: "text-red-12" }, neutral: {}, + success: {}, + error: {}, + warning: {}, + info: {}, }, }, + compoundVariants: [ + { type: "neutral", class: { root: "bg-sand-2 border-sand-6 text-sand-11", title: "text-sand-12" } }, + { type: "success", class: { root: "bg-green-2 border-green-6 text-green-11", title: "text-green-12" } }, + { type: "error", class: { root: "bg-red-2 border-red-6 text-red-11", title: "text-red-12" } }, + { type: "warning", class: { root: "bg-amber-2 border-amber-6 text-amber-11", title: "text-amber-12" } }, + { type: "info", class: { root: "bg-sky-2 border-sky-6 text-sky-11", title: "text-sky-12" } }, + ], defaultVariants: { type: "neutral", },