@vvedantb/ui 0.2.1 → 0.2.2

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
@@ -4,9 +4,9 @@ Shared React 19 web UI. Source-exported: no build step.
4
4
 
5
5
  | Import | What it is |
6
6
  | --- | --- |
7
- | `@vvedantb/ui` | Primitives (Radix-based) and AI elements. |
7
+ | `@vvedantb/ui` | Primitives (Radix-based), AI elements, `playChime` and `useReplyChime`. |
8
8
  | `@vvedantb/ui/cn` | `cn` class helper (clsx + tailwind-merge). |
9
- | `@vvedantb/ui/shell` | Router-free `SidebarHeader` and `PillTabs`. |
9
+ | `@vvedantb/ui/shell` | Router-free `SidebarHeader`, `PillTabs` and `SessionsHoverCard`. |
10
10
  | `@vvedantb/ui/app` | Alt-click confirm skip, update watcher, settings chrome, `useStableAuth`. See `app/README.md` in the repository. |
11
11
  | `@vvedantb/ui/tokens` | `tokens.css`: colour, radius, shadow, ease and font tokens for Tailwind v4. |
12
12
  | `@vvedantb/ui/tokens/css/*.css` | `index`, `base`, `dialog`, `modal`, `tabs`, `tabs-pills`, `utilities`, `scrollbar-thin`, `animations`, `compat-shadcn-rgb`. |
@@ -20,6 +20,67 @@ Shared React 19 web UI. Source-exported: no build step.
20
20
  @source "../node_modules/@vvedantb/ui/src/**/*.{ts,tsx}";
21
21
  ```
22
22
 
23
+ ## Reply chime
24
+
25
+ ```tsx
26
+ import { playChime, useReplyChime } from "@vvedantb/ui";
27
+
28
+ useReplyChime({ enabled: soundOn, isStreaming, threadId });
29
+ ```
30
+
31
+ - `playChime()`: soft rising two-note chime (WebAudio). One shared `AudioContext`, created on first use. It is a no-op on the server and when autoplay blocks audio. Calls less than 1 second apart play once.
32
+ - `useReplyChime`: chimes once when `isStreaming` goes from `true` to `false` for the same `threadId`. It is silent on the first sight of a thread, when `threadId` changes, and when `threadId` is null or undefined. While `enabled` is `false` it still tracks the edge, so enabling mid-turn does not replay a finish.
33
+
34
+ ## Sessions hover card
35
+
36
+ ```tsx
37
+ import { SessionsHoverCard } from "@vvedantb/ui/shell";
38
+
39
+ <SessionsHoverCard
40
+ header="Session title"
41
+ loadContent={() => fetchPreview(id)}
42
+ footer="Updated today"
43
+ >
44
+ <button type="button">Row</button>
45
+ </SessionsHoverCard>
46
+ ```
47
+
48
+ `children` is the trigger. Defaults: `openDelay` 400 ms, `closeDelay` 150 ms, `side="right"`, `align="start"`, `sideOffset` 8, width and padding `w-64 p-3` (override with `className`). `body` is a node or a function of `{ open }`. `loadContent` runs only while the card is open. Control the state with `open`, `defaultOpen` and `onOpenChange`. Fixture: `shell/examples/sessions-hover-card/`.
49
+
50
+ ## Toaster
51
+
52
+ ```tsx
53
+ import { toast } from "sonner";
54
+ import { SonnerToaster } from "@vvedantb/ui";
55
+
56
+ <SonnerToaster
57
+ theme={theme}
58
+ toastRadius="surface"
59
+ toastIcons="filled24"
60
+ closeButtonStyle="hoverChip"
61
+ sonnerVars
62
+ descriptionMuted
63
+ />;
64
+ ```
65
+
66
+ With no new props the toaster looks as it did in 0.2.1. Caller props merge with the package defaults instead of replacing them:
67
+
68
+ - `toastOptions.classNames`: per key, the package classes come first and the caller classes follow, so callers can override utilities. Other `toastOptions` fields (such as `duration`) take the caller value.
69
+ - `icons`: merged per key. With `toastIcons="filled24"` the caller overrides single icons and keeps the rest.
70
+ - `style`: merged over the package style. `className` is appended.
71
+
72
+ Opt-in props (all default to the current look):
73
+
74
+ | Prop | Values | Effect |
75
+ | --- | --- | --- |
76
+ | `toastRadius` | `"lg"` (default), `"surface"` | `rounded-lg` or `rounded-surface` (from `tokens/css/utilities.css`). |
77
+ | `toastIcons` | `"default"`, `"filled24"` | Sonner icons or 24px filled success, info, warning and error icons, a matching loader and a 14px close cross. |
78
+ | `closeButtonStyle` | `"default"`, `"hoverChip"` | Text-style close button or a round chip at the top-right, shown on hover and always on small screens. It turns on `closeButton` unless you set it. |
79
+ | `sonnerVars` | `false` (default), `true` | Sets `--normal-bg`, `--normal-border`, `--normal-text`, `--normal-bg-hover` and `--normal-border-hover` from `--color-card`, `--color-border`, `--color-foreground` and `--color-muted`, and `font-family: inherit`. |
80
+ | `descriptionMuted` | `false` (default), `true` | Description uses `text-muted-foreground!`, which beats sonner's dark-theme rule. |
81
+
82
+ Helpers: `createToasterClassNames`, `mergeToastClassNames`, `mergeToastOptions`, `mergeToasterStyle`, `filledToastIcons`. Fixture: `tokens/examples/toaster/`.
83
+
23
84
  ## Colour names in components
24
85
 
25
86
  Components use `text-muted-foreground`, `primary` and `--color-*` theme variables. They no longer use `text-muted`, `*-accent` or raw `var(--muted)` reads, which clash with shadcn names. `tokens.css` still defines `--color-muted` and `--color-accent`. `pnpm --filter @vvedantb/ui check:tokens` fails if a component brings the old names back (it also runs in `build`, `test` and `verify`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vvedantb/ui",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "license": "MIT",
5
5
  "description": "Shared React 19 UI for web apps: primitives, shell pieces, app helpers, design tokens, shared Tailwind v4 CSS and a Tailwind preset.",
6
6
  "type": "module",
@@ -61,7 +61,9 @@
61
61
  "tokens/src",
62
62
  "tokens/examples",
63
63
  "README.md",
64
- "LICENSE"
64
+ "LICENSE",
65
+ "!**/*.test.ts",
66
+ "!**/*.test.tsx"
65
67
  ],
66
68
  "sideEffects": [
67
69
  "./src/ui/sonner.css"
@@ -0,0 +1,148 @@
1
+ import { useEffect, useState } from "react";
2
+ import type { ReactNode } from "react";
3
+ import {
4
+ HoverCard,
5
+ HoverCardContent,
6
+ HoverCardTrigger,
7
+ } from "../../src/ui/hover-card";
8
+ import { cn } from "../../src/utils/cn";
9
+
10
+ export type SessionsHoverCardContext = { open: boolean };
11
+
12
+ export type SessionsHoverCardProps = {
13
+ /** The element that triggers the card, such as a list row. Rendered with `asChild`. */
14
+ children: ReactNode;
15
+ header?: ReactNode;
16
+ /**
17
+ * Body slot. A function runs only while the card is open, so it can start
18
+ * fetches for preview data.
19
+ */
20
+ body?: ReactNode | ((ctx: SessionsHoverCardContext) => ReactNode);
21
+ /** Runs each time the card opens. Its result replaces `body`. */
22
+ loadContent?: () => Promise<ReactNode> | ReactNode;
23
+ /** Shown while `loadContent` is pending. */
24
+ loadingFallback?: ReactNode;
25
+ footer?: ReactNode;
26
+ /** Controlled open state. */
27
+ open?: boolean;
28
+ defaultOpen?: boolean;
29
+ onOpenChange?: (open: boolean) => void;
30
+ /** Delay before opening, in milliseconds. */
31
+ openDelay?: number;
32
+ /** Delay before closing, in milliseconds. */
33
+ closeDelay?: number;
34
+ side?: "top" | "right" | "bottom" | "left";
35
+ align?: "start" | "center" | "end";
36
+ sideOffset?: number;
37
+ /** Merged over the default `w-64 p-3`. */
38
+ className?: string;
39
+ };
40
+
41
+ type LoadState = { status: "loading" } | { status: "ready"; node: ReactNode };
42
+
43
+ /** Mounts only while the card is open, so `loadContent` never runs while closed. */
44
+ function LoadedBody({
45
+ loadContent,
46
+ fallback,
47
+ }: {
48
+ loadContent: () => Promise<ReactNode> | ReactNode;
49
+ fallback: ReactNode;
50
+ }) {
51
+ const [state, setState] = useState<LoadState>({ status: "loading" });
52
+
53
+ useEffect(() => {
54
+ let cancelled = false;
55
+ Promise.resolve()
56
+ .then(loadContent)
57
+ .then((node) => {
58
+ if (!cancelled) setState({ status: "ready", node });
59
+ })
60
+ .catch(() => {
61
+ if (!cancelled) setState({ status: "ready", node: null });
62
+ });
63
+ return () => {
64
+ cancelled = true;
65
+ };
66
+ // Run once per open. A new `loadContent` identity must not refetch.
67
+ // eslint-disable-next-line react-hooks/exhaustive-deps
68
+ }, []);
69
+
70
+ return <>{state.status === "ready" ? state.node : fallback}</>;
71
+ }
72
+
73
+ /** Calls the render prop. Mounts only inside open content, never while closed. */
74
+ function RenderBody({
75
+ render,
76
+ open,
77
+ }: {
78
+ render: (ctx: SessionsHoverCardContext) => ReactNode;
79
+ open: boolean;
80
+ }) {
81
+ return <>{render({ open })}</>;
82
+ }
83
+
84
+ /**
85
+ * Hover card shell for list rows such as a sessions sidebar. It sets delay,
86
+ * position, width and padding, and gives header, body and footer slots. The
87
+ * app supplies the content.
88
+ */
89
+ export function SessionsHoverCard({
90
+ children,
91
+ header,
92
+ body,
93
+ loadContent,
94
+ loadingFallback = null,
95
+ footer,
96
+ open: openProp,
97
+ defaultOpen = false,
98
+ onOpenChange,
99
+ openDelay = 400,
100
+ closeDelay = 150,
101
+ side = "right",
102
+ align = "start",
103
+ sideOffset = 8,
104
+ className,
105
+ }: SessionsHoverCardProps) {
106
+ const [openState, setOpenState] = useState(defaultOpen);
107
+ const open = openProp ?? openState;
108
+
109
+ const handleOpenChange = (next: boolean) => {
110
+ setOpenState(next);
111
+ onOpenChange?.(next);
112
+ };
113
+
114
+ const bodyNode = loadContent ? (
115
+ <LoadedBody loadContent={loadContent} fallback={loadingFallback} />
116
+ ) : typeof body === "function" ? (
117
+ <RenderBody render={body} open={open} />
118
+ ) : (
119
+ body
120
+ );
121
+
122
+ return (
123
+ <HoverCard
124
+ open={open}
125
+ onOpenChange={handleOpenChange}
126
+ openDelay={openDelay}
127
+ closeDelay={closeDelay}
128
+ >
129
+ <HoverCardTrigger asChild>{children}</HoverCardTrigger>
130
+ <HoverCardContent
131
+ side={side}
132
+ align={align}
133
+ sideOffset={sideOffset}
134
+ className={cn("w-64 p-3", className)}
135
+ >
136
+ {header ? (
137
+ <div className="mb-2 text-sm font-medium text-foreground">{header}</div>
138
+ ) : null}
139
+ {bodyNode != null ? (
140
+ <div className="text-sm text-muted-foreground">{bodyNode}</div>
141
+ ) : null}
142
+ {footer ? (
143
+ <div className="mt-2 text-xs text-muted-foreground">{footer}</div>
144
+ ) : null}
145
+ </HoverCardContent>
146
+ </HoverCard>
147
+ );
148
+ }
@@ -4,3 +4,8 @@ export { PillTabs } from "./PillTabs.js";
4
4
  export type { PillTabItem, PillTabsProps } from "./PillTabs.js";
5
5
  export { cn } from "./cn.js";
6
6
  export type { ClassValue } from "./cn.js";
7
+ export { SessionsHoverCard } from "./SessionsHoverCard.js";
8
+ export type {
9
+ SessionsHoverCardContext,
10
+ SessionsHoverCardProps,
11
+ } from "./SessionsHoverCard.js";
@@ -0,0 +1,3 @@
1
+ export { playChime } from "./playChime";
2
+ export { useReplyChime, nextReplyChimeState } from "./useReplyChime";
3
+ export type { ReplyChimeInput, ReplyChimeWatch } from "./useReplyChime";
@@ -0,0 +1,76 @@
1
+ type Tone = {
2
+ frequency: number;
3
+ startOffsetS: number;
4
+ durationS: number;
5
+ peakGain: number;
6
+ };
7
+
8
+ /** A soft rising interval (A5 then D6). The second note overlaps the first. */
9
+ const REPLY_CHIME: readonly Tone[] = [
10
+ { frequency: 880, startOffsetS: 0, durationS: 0.28, peakGain: 0.14 },
11
+ { frequency: 1174.66, startOffsetS: 0.09, durationS: 0.28, peakGain: 0.14 },
12
+ ];
13
+
14
+ /** Long enough to avoid a click on attack. */
15
+ const ATTACK_S = 0.012;
16
+
17
+ /** Calls closer together than this are dropped, so overlapping events sound once. */
18
+ const MIN_INTERVAL_S = 1;
19
+
20
+ let sharedContext: AudioContext | null = null;
21
+ let lastPlayedAtS: number | null = null;
22
+
23
+ function getAudioContext(): AudioContext | null {
24
+ if (sharedContext) return sharedContext;
25
+ if (typeof window === "undefined" || !window.AudioContext) return null;
26
+ try {
27
+ sharedContext = new window.AudioContext();
28
+ } catch {
29
+ return null;
30
+ }
31
+ return sharedContext;
32
+ }
33
+
34
+ /**
35
+ * Plays a soft rising two-note chime with WebAudio.
36
+ *
37
+ * The context is created on first use and shared. The call is a no-op on the
38
+ * server, without WebAudio, or while autoplay policy blocks audio.
39
+ */
40
+ export function playChime(): void {
41
+ const context = getAudioContext();
42
+ if (!context) return;
43
+
44
+ if (context.state === "suspended") {
45
+ try {
46
+ context.resume().catch(() => undefined);
47
+ } catch {
48
+ return;
49
+ }
50
+ }
51
+
52
+ const start = context.currentTime;
53
+ if (lastPlayedAtS !== null && start - lastPlayedAtS < MIN_INTERVAL_S) return;
54
+ lastPlayedAtS = start;
55
+
56
+ try {
57
+ for (const tone of REPLY_CHIME) {
58
+ const oscillator = context.createOscillator();
59
+ const envelope = context.createGain();
60
+ oscillator.type = "sine";
61
+ oscillator.frequency.value = tone.frequency;
62
+
63
+ const toneStart = start + tone.startOffsetS;
64
+ const toneEnd = toneStart + tone.durationS;
65
+ envelope.gain.setValueAtTime(0, toneStart);
66
+ envelope.gain.linearRampToValueAtTime(tone.peakGain, toneStart + ATTACK_S);
67
+ envelope.gain.exponentialRampToValueAtTime(0.0001, toneEnd);
68
+
69
+ oscillator.connect(envelope).connect(context.destination);
70
+ oscillator.start(toneStart);
71
+ oscillator.stop(toneEnd);
72
+ }
73
+ } catch {
74
+ // Audio is optional. Stay silent on any failure.
75
+ }
76
+ }
@@ -0,0 +1,62 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { playChime } from "./playChime";
3
+
4
+ export type ReplyChimeWatch = {
5
+ threadId: string;
6
+ wasStreaming: boolean;
7
+ };
8
+
9
+ export type ReplyChimeInput = {
10
+ enabled: boolean;
11
+ isStreaming: boolean;
12
+ threadId: string | null | undefined;
13
+ };
14
+
15
+ /**
16
+ * Pure transition for the reply chime. Returns the state to remember and
17
+ * whether to chime.
18
+ *
19
+ * - No thread id: reset tracking and stay silent.
20
+ * - First observation of a thread (or a new thread): silent.
21
+ * - Chime only on streaming to finished for the same thread, while enabled.
22
+ * - The edge is tracked while disabled, so enabling mid-turn does not replay
23
+ * a finish that already happened.
24
+ */
25
+ export function nextReplyChimeState(
26
+ previous: ReplyChimeWatch | null,
27
+ { enabled, isStreaming, threadId }: ReplyChimeInput,
28
+ ): { next: ReplyChimeWatch | null; chime: boolean } {
29
+ if (threadId == null || threadId === "") {
30
+ return { next: null, chime: false };
31
+ }
32
+ const next = { threadId, wasStreaming: isStreaming };
33
+ if (!previous || previous.threadId !== threadId) {
34
+ return { next, chime: false };
35
+ }
36
+ const finished = previous.wasStreaming && !isStreaming;
37
+ return { next, chime: finished && enabled };
38
+ }
39
+
40
+ /**
41
+ * Plays the reply chime once when a thread finishes streaming.
42
+ *
43
+ * Silent on the first observation of a thread, so opening a settled thread
44
+ * does not chime for an earlier turn.
45
+ */
46
+ export function useReplyChime({
47
+ enabled,
48
+ isStreaming,
49
+ threadId,
50
+ }: ReplyChimeInput): void {
51
+ const watchedRef = useRef<ReplyChimeWatch | null>(null);
52
+
53
+ useEffect(() => {
54
+ const { next, chime } = nextReplyChimeState(watchedRef.current, {
55
+ enabled,
56
+ isStreaming,
57
+ threadId,
58
+ });
59
+ watchedRef.current = next;
60
+ if (chime) playChime();
61
+ }, [enabled, isStreaming, threadId]);
62
+ }
package/src/index.ts CHANGED
@@ -96,3 +96,5 @@ export * from "./ai-elements/shimmer";
96
96
  export * from "./ai-elements/sources";
97
97
  export * from "./ai-elements/speech-input";
98
98
  export * from "./ai-elements/suggestion";
99
+
100
+ export * from "./audio";
@@ -0,0 +1,94 @@
1
+ import { Spinner } from "./spinner";
2
+
3
+ // 24px filled icons for `SonnerToaster` (`toastIcons="filled24"`). Self-contained
4
+ // SVGs, tinted with package colour tokens. Sonner wraps each in its `data-icon` slot.
5
+
6
+ function InfoIcon() {
7
+ return (
8
+ <svg
9
+ aria-hidden="true"
10
+ className="h-6 w-6 fill-current text-primary"
11
+ fill="none"
12
+ viewBox="0 0 24 24"
13
+ xmlns="http://www.w3.org/2000/svg"
14
+ >
15
+ <path d="M12 22C17.51 22 22 17.51 22 12C22 6.49 17.51 2 12 2C6.49 2 2 6.49 2 12C2 17.51 6.49 22 12 22ZM12.75 16C12.75 16.41 12.41 16.75 12 16.75C11.59 16.75 11.25 16.41 11.25 16L11.25 11C11.25 10.59 11.59 10.25 12 10.25C12.41 10.25 12.75 10.59 12.75 11L12.75 16ZM11.08 7.62C11.13 7.49 11.2 7.39 11.29 7.29C11.39 7.2 11.5 7.13 11.62 7.08C11.74 7.03 11.87 7 12 7C12.13 7 12.26 7.03 12.38 7.08C12.5 7.13 12.61 7.2 12.71 7.29C12.8 7.39 12.87 7.49 12.92 7.62C12.97 7.74 13 7.87 13 8C13 8.13 12.97 8.26 12.92 8.38C12.87 8.5 12.8 8.61 12.71 8.71C12.61 8.8 12.5 8.87 12.38 8.92C12.14 9.02 11.86 9.02 11.62 8.92C11.5 8.87 11.39 8.8 11.29 8.71C11.2 8.61 11.13 8.5 11.08 8.38C11.03 8.26 11 8.13 11 8C11 7.87 11.03 7.74 11.08 7.62Z" />
16
+ </svg>
17
+ );
18
+ }
19
+
20
+ function SuccessIcon() {
21
+ return (
22
+ <svg
23
+ aria-hidden="true"
24
+ className="h-6 w-6 fill-current text-success"
25
+ fill="none"
26
+ viewBox="0 0 24 24"
27
+ xmlns="http://www.w3.org/2000/svg"
28
+ >
29
+ <path d="M12 2C6.49 2 2 6.49 2 12C2 17.51 6.49 22 12 22C17.51 22 22 17.51 22 12C22 6.49 17.51 2 12 2ZM16.78 9.7L11.11 15.37C10.97 15.51 10.78 15.59 10.58 15.59C10.38 15.59 10.19 15.51 10.05 15.37L7.22 12.54C6.93 12.25 6.93 11.77 7.22 11.48C7.51 11.19 7.99 11.19 8.28 11.48L10.58 13.78L15.72 8.64C16.01 8.35 16.49 8.35 16.78 8.64C17.07 8.93 17.07 9.4 16.78 9.7Z" />
30
+ </svg>
31
+ );
32
+ }
33
+
34
+ function WarningIcon() {
35
+ return (
36
+ <svg
37
+ aria-hidden="true"
38
+ className="h-6 w-6 fill-current text-warning"
39
+ fill="none"
40
+ viewBox="0 0 24 24"
41
+ xmlns="http://www.w3.org/2000/svg"
42
+ >
43
+ <path
44
+ clipRule="evenodd"
45
+ d="M3 10.417C3 7.219 3 5.62 3.378 5.082C3.755 4.545 5.258 4.03 8.265 3.001L8.838 2.805C10.405 2.268 11.188 2 12 2C12.812 2 13.595 2.268 15.162 2.805L15.735 3.001C18.742 4.03 20.245 4.545 20.622 5.082C21 5.62 21 7.22 21 10.417V11.991C21 17.629 16.761 20.366 14.101 21.527C13.38 21.842 13.02 22 12 22C10.98 22 10.62 21.842 9.899 21.527C7.239 20.365 3 17.63 3 11.991V10.417ZM12 7.25C12.1989 7.25 12.3897 7.32902 12.5303 7.46967C12.671 7.61032 12.75 7.80109 12.75 8V12C12.75 12.1989 12.671 12.3897 12.5303 12.5303C12.3897 12.671 12.1989 12.75 12 12.75C11.8011 12.75 11.6103 12.671 11.4697 12.5303C11.329 12.3897 11.25 12.1989 11.25 12V8C11.25 7.80109 11.329 7.61032 11.4697 7.46967C11.6103 7.32902 11.8011 7.25 12 7.25ZM12 16C12.2652 16 12.5196 15.8946 12.7071 15.7071C12.8946 15.5196 13 15.2652 13 15C13 14.7348 12.8946 14.4804 12.7071 14.2929C12.5196 14.1054 12.2652 14 12 14C11.7348 14 11.4804 14.1054 11.2929 14.2929C11.1054 14.4804 11 14.7348 11 15C11 15.2652 11.1054 15.5196 11.2929 15.7071C11.4804 15.8946 11.7348 16 12 16Z"
46
+ fillRule="evenodd"
47
+ />
48
+ </svg>
49
+ );
50
+ }
51
+
52
+ function ErrorIcon() {
53
+ return (
54
+ <svg
55
+ aria-hidden="true"
56
+ className="h-6 w-6 fill-current text-destructive"
57
+ fill="none"
58
+ viewBox="0 0 20 20"
59
+ xmlns="http://www.w3.org/2000/svg"
60
+ >
61
+ <path d="M17.51 3.85L11.57 0.42C10.6 -0.14 9.4 -0.14 8.42 0.42L2.49 3.85C1.52 4.41 0.919998 5.45 0.919998 6.58V13.42C0.919998 14.54 1.52 15.58 2.49 16.15L8.43 19.58C9.4 20.14 10.6 20.14 11.58 19.58L17.52 16.15C18.49 15.59 19.09 14.55 19.09 13.42V6.58C19.08 5.45 18.48 4.42 17.51 3.85ZM9.25 5.75C9.25 5.34 9.59 5 10 5C10.41 5 10.75 5.34 10.75 5.75V11C10.75 11.41 10.41 11.75 10 11.75C9.59 11.75 9.25 11.41 9.25 11V5.75ZM10.92 14.63C10.87 14.75 10.8 14.86 10.71 14.96C10.52 15.15 10.27 15.25 10 15.25C9.87 15.25 9.74 15.22 9.62 15.17C9.49 15.12 9.39 15.05 9.29 14.96C9.2 14.86 9.13 14.75 9.07 14.63C9.02 14.51 9 14.38 9 14.25C9 13.99 9.1 13.73 9.29 13.54C9.39 13.45 9.49 13.38 9.62 13.33C9.99 13.17 10.43 13.26 10.71 13.54C10.8 13.64 10.87 13.74 10.92 13.87C10.97 13.99 11 14.12 11 14.25C11 14.38 10.97 14.51 10.92 14.63Z" />
62
+ </svg>
63
+ );
64
+ }
65
+
66
+ function CloseIcon() {
67
+ return (
68
+ <svg
69
+ aria-hidden="true"
70
+ className="h-3.5 w-3.5"
71
+ fill="none"
72
+ stroke="currentColor"
73
+ strokeLinecap="round"
74
+ strokeLinejoin="round"
75
+ strokeWidth={2}
76
+ viewBox="0 0 24 24"
77
+ xmlns="http://www.w3.org/2000/svg"
78
+ >
79
+ <path d="M18 6L6 18M6 6l12 12" />
80
+ </svg>
81
+ );
82
+ }
83
+
84
+ /** 24px success / info / warning / error icons, a matching loader and a 14px close cross. */
85
+ const filledToastIcons = {
86
+ success: <SuccessIcon />,
87
+ info: <InfoIcon />,
88
+ warning: <WarningIcon />,
89
+ error: <ErrorIcon />,
90
+ loading: <Spinner size="md" aria-hidden="true" />,
91
+ close: <CloseIcon />,
92
+ };
93
+
94
+ export { filledToastIcons };
package/src/ui/sonner.tsx CHANGED
@@ -1,47 +1,180 @@
1
+ import type { CSSProperties } from "react";
1
2
  import { Toaster, type ToasterProps } from "sonner";
3
+ import { filledToastIcons } from "./sonner-icons";
2
4
  import "./sonner.css";
3
5
 
4
- // sonner toaster aligned with flat tonal surfaces `unstyled: true` strips sonner's default visual styling so our classes are
5
- // the single source of truth toast surfaces use `glass, panel, strong` (flat
6
- // popover fill) plus `smooth-shadow-ring-md` `!font, sans` is forced on the toaster section because sonner sets its own
7
- // `font, family` on `[data, sonner, toaster]` from a stylesheet that ships with
8
- // the package (loaded after our globals), so plain inheritance from `<body>`
9
- // loses the cascade the `!important` here guarantees instrument sans wins, toasts inherit from the section, so this single class covers everything variant icons (success / error / warning / info) are tinted via a
10
- // `data, icon` descendant selector, leaving the rest of the toast neutral callers should pass `theme` from `next, themes` so sonner's internal
11
- // `data, theme` attribute matches the active app theme (used by its focus
12
- // ring and a few internal states that aren't reachable via classnames) stacking: `expand={false}` keeps toasts collapsed (offset + scale) hovering
13
- // the stack expands them `sonner.css` hides back, toast content while collapsed
14
- // because `unstyled` disables sonner's built, in `data, styled` stack rules
15
- function SonnerToaster(props: ToasterProps) {
6
+ // Sonner toaster aligned with flat tonal surfaces. `unstyled: true` strips
7
+ // sonner's default visual styling so our classes are the single source of
8
+ // truth. Toast surfaces use `glass-panel-strong` (flat popover fill) plus
9
+ // `smooth-shadow-ring-md`. `!font-sans` is forced on the toaster section because
10
+ // sonner sets its own `font-family` on `[data-sonner-toaster]` from a stylesheet
11
+ // that loads after app globals. Variant icons (success / error / warning / info)
12
+ // are tinted via a `data-icon` descendant selector, leaving the rest of the toast
13
+ // neutral. Callers should pass `theme` so sonner's internal `data-theme`
14
+ // attribute matches the active app theme.
15
+ //
16
+ // Stacking: `expand={false}` keeps toasts collapsed (offset + scale). Hovering
17
+ // the stack expands them. `sonner.css` hides back-toast content while collapsed
18
+ // because `unstyled` disables sonner's built-in `data-styled` stack rules.
19
+ //
20
+ // Caller props merge with these defaults instead of replacing them: see
21
+ // `mergeToastClassNames`, `mergeToasterStyle` and the `SonnerToaster` props.
22
+
23
+ type ToastClassNames = NonNullable<
24
+ NonNullable<ToasterProps["toastOptions"]>["classNames"]
25
+ >;
26
+ type ToasterStyle = CSSProperties & Record<`--${string}`, string>;
27
+
28
+ type SonnerToasterOptions = {
29
+ /** `"lg"` (default) uses `rounded-lg`. `"surface"` uses `rounded-surface`. */
30
+ toastRadius?: "lg" | "surface";
31
+ /** `"default"` keeps sonner's icons. `"filled24"` uses 24px filled icons. */
32
+ toastIcons?: "default" | "filled24";
33
+ /**
34
+ * `"default"` keeps the text-style close button. `"hoverChip"` uses a round chip at the
35
+ * top-right, shown on hover (always on small screens) and enables sonner's `closeButton`
36
+ * unless the caller sets it.
37
+ */
38
+ closeButtonStyle?: "default" | "hoverChip";
39
+ /** When true, set sonner's `--normal-*` variables from `--color-*` tokens and `font-family: inherit`. */
40
+ sonnerVars?: boolean;
41
+ /** When true, descriptions use `text-muted-foreground!` so sonner's dark rule cannot win. */
42
+ descriptionMuted?: boolean;
43
+ };
44
+
45
+ const hoverChipCloseButtonClassName =
46
+ "pointer-events-none absolute -right-2 -top-2 z-10 flex h-6 w-6 items-center justify-center rounded-full border border-border bg-card/95 text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring group-hover:pointer-events-auto group-hover:opacity-100 max-sm:pointer-events-auto max-sm:opacity-100 max-sm:hit-target";
47
+
48
+ const defaultCloseButtonClassName =
49
+ "rounded-lg bg-default px-2.5 py-1 text-xs font-medium text-foreground transition-colors hover:bg-default/78";
50
+
51
+ /** Default `classNames`, with the opt-in variants applied. No options gives the 0.2.1 defaults. */
52
+ function createToasterClassNames({
53
+ toastRadius = "lg",
54
+ closeButtonStyle = "default",
55
+ descriptionMuted = false,
56
+ }: Pick<
57
+ SonnerToasterOptions,
58
+ "toastRadius" | "closeButtonStyle" | "descriptionMuted"
59
+ > = {}): ToastClassNames {
60
+ const radius = toastRadius === "surface" ? "rounded-surface" : "rounded-lg";
61
+ return {
62
+ toast: `group toast glass-panel-strong flex w-full items-center gap-3 ${radius} px-4 py-3 text-sm text-foreground smooth-shadow-ring-md`,
63
+ title: "font-medium leading-snug",
64
+ description: descriptionMuted
65
+ ? "text-[13px] leading-snug text-muted-foreground!"
66
+ : "text-[13px] leading-snug text-muted-foreground",
67
+ actionButton:
68
+ "rounded-lg bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90",
69
+ cancelButton:
70
+ "rounded-lg bg-surface-secondary px-2.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-surface-tertiary",
71
+ closeButton:
72
+ closeButtonStyle === "hoverChip"
73
+ ? hoverChipCloseButtonClassName
74
+ : defaultCloseButtonClassName,
75
+ loader: "text-muted-foreground",
76
+ success: "[&_[data-icon]]:text-success",
77
+ error: "[&_[data-icon]]:text-danger",
78
+ warning: "[&_[data-icon]]:text-warning",
79
+ info: "[&_[data-icon]]:text-primary",
80
+ };
81
+ }
82
+
83
+ /** Per key, joins the default class string and the caller string (defaults first, so callers win). */
84
+ function mergeToastClassNames(
85
+ defaults: ToastClassNames,
86
+ overrides?: ToastClassNames,
87
+ ): ToastClassNames {
88
+ if (!overrides) return defaults;
89
+ const merged: Record<string, string | undefined> = { ...defaults };
90
+ for (const [key, value] of Object.entries(overrides)) {
91
+ if (!value) continue;
92
+ const base = merged[key];
93
+ merged[key] = base ? `${base} ${value}` : value;
94
+ }
95
+ return merged as ToastClassNames;
96
+ }
97
+
98
+ /** Sonner variables read from `--color-*` tokens. Work with `tokens.css` and the compat file. */
99
+ const sonnerVarsStyle: ToasterStyle = {
100
+ fontFamily: "inherit",
101
+ "--normal-bg": "var(--color-card)",
102
+ "--normal-border": "var(--color-border)",
103
+ "--normal-text": "var(--color-foreground)",
104
+ "--normal-bg-hover": "var(--color-muted)",
105
+ "--normal-border-hover": "var(--color-border)",
106
+ };
107
+
108
+ /** `{ ...defaults, ...overrides }`. The package adds no style keys unless `sonnerVars` is on. */
109
+ function mergeToasterStyle(
110
+ defaults: CSSProperties | undefined,
111
+ overrides: CSSProperties | undefined,
112
+ ): CSSProperties | undefined {
113
+ if (!defaults) return overrides;
114
+ return overrides ? { ...defaults, ...overrides } : defaults;
115
+ }
116
+
117
+ /** Package defaults first. Caller wins per field, except `classNames`, which merge. */
118
+ function mergeToastOptions(
119
+ defaults: NonNullable<ToasterProps["toastOptions"]>,
120
+ overrides?: ToasterProps["toastOptions"],
121
+ ): NonNullable<ToasterProps["toastOptions"]> {
122
+ const { classNames, ...rest } = overrides ?? {};
123
+ return {
124
+ ...defaults,
125
+ ...rest,
126
+ classNames: mergeToastClassNames(defaults.classNames ?? {}, classNames),
127
+ };
128
+ }
129
+
130
+ function SonnerToaster({
131
+ toastRadius,
132
+ toastIcons = "default",
133
+ closeButtonStyle = "default",
134
+ sonnerVars = false,
135
+ descriptionMuted,
136
+ toastOptions,
137
+ icons,
138
+ style,
139
+ className,
140
+ closeButton,
141
+ ...props
142
+ }: ToasterProps & SonnerToasterOptions) {
143
+ const mergedIcons =
144
+ toastIcons === "filled24" ? { ...filledToastIcons, ...icons } : icons;
16
145
  return (
17
146
  <Toaster
18
147
  expand={false}
19
148
  visibleToasts={4}
20
149
  gap={12}
21
- className="toaster group !font-sans"
22
- toastOptions={{
23
- unstyled: true,
24
- classNames: {
25
- toast:
26
- "group toast glass-panel-strong flex w-full items-center gap-3 rounded-lg px-4 py-3 text-sm text-foreground smooth-shadow-ring-md",
27
- title: "font-medium leading-snug",
28
- description: "text-[13px] leading-snug text-muted-foreground",
29
- actionButton:
30
- "rounded-lg bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90",
31
- cancelButton:
32
- "rounded-lg bg-surface-secondary px-2.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-surface-tertiary",
33
- closeButton:
34
- "rounded-lg bg-default px-2.5 py-1 text-xs font-medium text-foreground transition-colors hover:bg-default/78",
35
- loader: "text-muted-foreground",
36
- success: "[&_[data-icon]]:text-success",
37
- error: "[&_[data-icon]]:text-danger",
38
- warning: "[&_[data-icon]]:text-warning",
39
- info: "[&_[data-icon]]:text-primary",
40
- },
41
- }}
42
150
  {...props}
151
+ closeButton={closeButton ?? (closeButtonStyle === "hoverChip" ? true : undefined)}
152
+ className={className ? `toaster group !font-sans ${className}` : "toaster group !font-sans"}
153
+ icons={mergedIcons}
154
+ style={mergeToasterStyle(sonnerVars ? sonnerVarsStyle : undefined, style)}
155
+ toastOptions={mergeToastOptions(
156
+ {
157
+ unstyled: true,
158
+ classNames: createToasterClassNames({
159
+ toastRadius,
160
+ closeButtonStyle,
161
+ descriptionMuted,
162
+ }),
163
+ },
164
+ toastOptions,
165
+ )}
43
166
  />
44
167
  );
45
168
  }
46
169
 
47
- export { SonnerToaster };
170
+ export {
171
+ SonnerToaster,
172
+ createToasterClassNames,
173
+ hoverChipCloseButtonClassName,
174
+ mergeToastClassNames,
175
+ mergeToastOptions,
176
+ mergeToasterStyle,
177
+ sonnerVarsStyle,
178
+ };
179
+ export { filledToastIcons } from "./sonner-icons";
180
+ export type { SonnerToasterOptions };
package/tokens/README.md CHANGED
@@ -13,7 +13,7 @@ Design tokens and shared Tailwind v4 CSS for the vvedantb web apps, plus JS toke
13
13
  | `@vvedantb/ui/tokens/css/modal.css` | `src/css/modal.css` | `.t-modal`, `.is-open`, `.is-closing` and `t-modal-presence-wait` (`@layer components`). |
14
14
  | `@vvedantb/ui/tokens/css/tabs.css` | `src/css/tabs.css` | `.t-tab`, active colour, `.t-tabs-pill`, small-screen `.t-tabs` scrolling. |
15
15
  | `@vvedantb/ui/tokens/css/tabs-pills.css` | `src/css/tabs-pills.css` | `--tabs-*` colour variables, `.t-tabs--segmented`, `.t-tabs--pills` and pill hover / active fills. |
16
- | `@vvedantb/ui/tokens/css/utilities.css` | `src/css/utilities.css` | `@utility hit-target` and `@utility reveal-on-hover`. |
16
+ | `@vvedantb/ui/tokens/css/utilities.css` | `src/css/utilities.css` | `@utility hit-target`, `@utility reveal-on-hover` and `@utility rounded-surface` (`clamp(0.75rem, var(--radius), 1rem)`). |
17
17
  | `@vvedantb/ui/tokens/css/scrollbar-thin.css` | `src/css/scrollbar-thin.css` | `.scrollbar-thin` (`@layer utilities`, always emitted). |
18
18
  | `@vvedantb/ui/tokens/css/animations.css` | `src/css/animations.css` | `animate-aurora`, `animate-fade-in-up`, `animate-indeterminate` as `@theme` (CSS-first apps). |
19
19
  | `@vvedantb/ui/tokens/css/compat-shadcn-rgb.css`, `@vvedantb/ui/tokens/compat/shadcn-rgb.css` | `src/css/compat-shadcn-rgb.css` | Replaces `tokens.css` for apps with shadcn-style `R G B` channel variables. Defines only the UI-only `--color-*` names as `rgb(var(--x) / alpha)` references, `--radius-field`, `--scrollbar`, `--field-radius` and a `glass-panel-strong` fallback. Import it **or** `tokens.css`, never both. |
@@ -0,0 +1,53 @@
1
+ import { useEffect, useState } from "react";
2
+ import { toast } from "sonner";
3
+ import { Button } from "../../../src/ui/button";
4
+ import { SonnerToaster } from "../../../src/ui/sonner";
5
+
6
+ function initialDark(): boolean {
7
+ return new URLSearchParams(window.location.search).get("theme") === "dark";
8
+ }
9
+
10
+ function fire() {
11
+ toast.success("Changes saved", {
12
+ description: "Your settings were updated and will apply to new sessions.",
13
+ duration: Infinity,
14
+ });
15
+ }
16
+
17
+ export function Fixture() {
18
+ const [dark, setDark] = useState(initialDark);
19
+
20
+ // Sonner portals toasts onto `document.body`, so `.dark` must live on `<html>`
21
+ // (not an inner wrapper) for token / Tailwind dark styles to reach them.
22
+ useEffect(() => {
23
+ document.documentElement.classList.toggle("dark", dark);
24
+ return () => {
25
+ document.documentElement.classList.remove("dark");
26
+ };
27
+ }, [dark]);
28
+
29
+ // Fire one sample toast on load so a screenshot captures it.
30
+ useEffect(() => {
31
+ const id = window.setTimeout(fire, 150);
32
+ return () => window.clearTimeout(id);
33
+ }, []);
34
+
35
+ return (
36
+ <>
37
+ <main className="min-h-screen bg-background p-8 text-foreground">
38
+ <div className="flex gap-3">
39
+ <Button onClick={() => setDark((d) => !d)}>Toggle theme</Button>
40
+ <Button onClick={fire}>Show toast</Button>
41
+ </div>
42
+ </main>
43
+ <SonnerToaster
44
+ theme={dark ? "dark" : "light"}
45
+ toastRadius="surface"
46
+ toastIcons="filled24"
47
+ closeButtonStyle="hoverChip"
48
+ sonnerVars
49
+ descriptionMuted
50
+ />
51
+ </>
52
+ );
53
+ }
@@ -0,0 +1,18 @@
1
+ # toaster fixture
2
+
3
+ Renders `SonnerToaster` with every opt-in prop on (`toastRadius="surface"`, `toastIcons="filled24"`, `closeButtonStyle="hoverChip"`, `sonnerVars`, `descriptionMuted`). It fires one sample toast with a title and a description on load. The toast stays open. Use "Show toast" to fire another.
4
+
5
+ | Page | Stylesheet |
6
+ | --- | --- |
7
+ | `/tokens.html` | `tokens.css` plus `utilities.css` (`styles-tokens.css`) |
8
+ | `/compat.html` | `compat-shadcn-rgb.css` plus `utilities.css`, with sample `R G B` variables (`styles-compat.css`) |
9
+
10
+ Each stylesheet declares `glass-panel-strong` (tokens page only) and `smooth-shadow-ring-md`. The app owns those toast classes. The compat page also defines `--success` and `--warning` and maps them to `--color-success` and `--color-warning`.
11
+
12
+ Run it from this folder (it is not a workspace member, and React resolves from the parent `packages/ui` install):
13
+
14
+ ```sh
15
+ pnpm install --ignore-workspace && pnpm dev
16
+ ```
17
+
18
+ Open the printed URL plus `/tokens.html` or `/compat.html`. Add `?theme=dark` for dark mode, or use "Toggle theme". Hover the toast to see the close chip. `Fixture.tsx` is not part of the package typecheck.
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>toaster: compat-shadcn-rgb.css</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="./main-compat.tsx"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,5 @@
1
+ import { createRoot } from "react-dom/client";
2
+ import { Fixture } from "./Fixture";
3
+ import "./styles-compat.css";
4
+
5
+ createRoot(document.getElementById("root")!).render(<Fixture />);
@@ -0,0 +1,5 @@
1
+ import { createRoot } from "react-dom/client";
2
+ import { Fixture } from "./Fixture";
3
+ import "./styles-tokens.css";
4
+
5
+ createRoot(document.getElementById("root")!).render(<Fixture />);
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "toaster-fixture",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "vite",
7
+ "build": "vite build"
8
+ },
9
+ "devDependencies": {
10
+ "@tailwindcss/vite": "4.3.3",
11
+ "tailwindcss": "4.3.3",
12
+ "vite": "^7.0.0"
13
+ }
14
+ }
@@ -0,0 +1,82 @@
1
+ @import "tailwindcss";
2
+ @import "../../src/css/compat-shadcn-rgb.css";
3
+ @import "../../src/css/utilities.css";
4
+ @source "../../../src/**/*.{ts,tsx}";
5
+ @source "./*.tsx";
6
+ @custom-variant dark (&:where(.dark, .dark *));
7
+
8
+ /* Sample shadcn-style RGB channel variables owned by the app. */
9
+ :root {
10
+ --background: 244 245 246;
11
+ --foreground: 24 24 27;
12
+ --card: 255 255 255;
13
+ --card-foreground: 24 24 27;
14
+ --popover: 255 255 255;
15
+ --popover-foreground: 24 24 27;
16
+ --primary: 37 99 235;
17
+ --primary-foreground: 255 255 255;
18
+ --secondary: 235 236 238;
19
+ --secondary-foreground: 24 24 27;
20
+ --muted: 235 236 238;
21
+ --muted-foreground: 100 100 110;
22
+ --accent: 235 236 238;
23
+ --accent-foreground: 24 24 27;
24
+ --destructive: 220 38 38;
25
+ --destructive-foreground: 255 255 255;
26
+ --border: 224 225 228;
27
+ --input: 224 225 228;
28
+ --ring: 37 99 235;
29
+ --success: 22 163 74;
30
+ --warning: 217 119 6;
31
+ --radius: 0.75rem;
32
+ }
33
+
34
+ .dark {
35
+ --background: 15 15 17;
36
+ --foreground: 244 244 245;
37
+ --card: 28 28 31;
38
+ --card-foreground: 244 244 245;
39
+ --popover: 28 28 31;
40
+ --popover-foreground: 244 244 245;
41
+ --primary: 96 165 250;
42
+ --primary-foreground: 15 15 17;
43
+ --secondary: 39 39 43;
44
+ --secondary-foreground: 244 244 245;
45
+ --muted: 39 39 43;
46
+ --muted-foreground: 161 161 170;
47
+ --accent: 39 39 43;
48
+ --accent-foreground: 244 244 245;
49
+ --destructive: 248 113 113;
50
+ --destructive-foreground: 15 15 17;
51
+ --border: 46 46 51;
52
+ --input: 46 46 51;
53
+ --ring: 96 165 250;
54
+ --success: 74 222 128;
55
+ --warning: 251 191 36;
56
+ }
57
+
58
+ @theme inline {
59
+ --color-background: rgb(var(--background));
60
+ --color-foreground: rgb(var(--foreground));
61
+ --color-border: rgb(var(--border));
62
+ --color-primary: rgb(var(--primary));
63
+ --color-primary-foreground: rgb(var(--primary-foreground));
64
+ --color-muted-foreground: rgb(var(--muted-foreground));
65
+ --color-destructive: rgb(var(--destructive));
66
+ --color-ring: rgb(var(--ring));
67
+ --color-success: rgb(var(--success));
68
+ --color-warning: rgb(var(--warning));
69
+ }
70
+
71
+ /* Toast class the app owns (not part of the compat file). */
72
+ @utility smooth-shadow-ring-md {
73
+ box-shadow:
74
+ 0 0 0 1px rgb(var(--border) / 0.6),
75
+ 0 4px 12px rgb(0 0 0 / 0.08);
76
+ }
77
+
78
+ body {
79
+ background: rgb(var(--background));
80
+ color: rgb(var(--foreground));
81
+ font-family: system-ui, sans-serif;
82
+ }
@@ -0,0 +1,16 @@
1
+ @import "tailwindcss";
2
+ @import "../../src/tokens.css";
3
+ @import "../../src/css/utilities.css";
4
+ @source "../../../src/**/*.{ts,tsx}";
5
+ @source "./*.tsx";
6
+
7
+ /* Toast classes the app owns (not part of tokens.css). */
8
+ @utility glass-panel-strong {
9
+ background-color: color-mix(in oklch, var(--color-card) 95%, transparent);
10
+ backdrop-filter: blur(12px);
11
+ }
12
+ @utility smooth-shadow-ring-md {
13
+ box-shadow:
14
+ 0 0 0 1px color-mix(in srgb, var(--color-border) 60%, transparent),
15
+ 0 4px 12px rgb(0 0 0 / 0.08);
16
+ }
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>toaster: tokens.css</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="./main-tokens.tsx"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7
+ "jsx": "react-jsx",
8
+ "strict": true,
9
+ "noEmit": true,
10
+ "skipLibCheck": true,
11
+ "isolatedModules": true
12
+ },
13
+ "include": ["*.ts", "*.tsx"]
14
+ }
@@ -0,0 +1,12 @@
1
+ import tailwindcss from "@tailwindcss/vite";
2
+ import { defineConfig } from "vite";
3
+
4
+ // React, react-dom and the @vvedantb/ui peers resolve from packages/ui/node_modules
5
+ // (this folder sits inside that package), so the fixture installs only the build tools.
6
+ export default defineConfig({
7
+ plugins: [tailwindcss()],
8
+ esbuild: { jsx: "automatic" },
9
+ resolve: { dedupe: ["react", "react-dom"] },
10
+ build: { rollupOptions: { input: ["tokens.html", "compat.html"] } },
11
+ server: { fs: { allow: ["../../.."] } },
12
+ });
@@ -23,11 +23,18 @@
23
23
  * --accent, --accent-foreground, --destructive, --destructive-foreground,
24
24
  * --border, --input, --ring and --radius (all colours as "R G B").
25
25
  *
26
+ * `--color-card` and `--color-muted` are references to `--card` and `--muted`.
27
+ * `SonnerToaster sonnerVars` reads them, along with `--color-border` and
28
+ * `--color-foreground`. An app that already maps those names keeps its own
29
+ * mapping if it declares them after this import.
30
+ *
26
31
  * Alpha values follow common shadcn-RGB classes (ring/35, ring/50,
27
32
  * destructive/20, placeholder /90, scrollbar /35).
28
33
  */
29
34
 
30
35
  @theme inline {
36
+ --color-card: rgb(var(--card));
37
+ --color-muted: rgb(var(--muted));
31
38
  --color-default: rgb(var(--secondary));
32
39
  --color-default-foreground: rgb(var(--secondary-foreground));
33
40
  --color-surface: rgb(var(--card));
@@ -32,3 +32,8 @@
32
32
  }
33
33
  }
34
34
  }
35
+
36
+ /* Surface radius: follows the theme `--radius`, kept between 0.75rem and 1rem. */
37
+ @utility rounded-surface {
38
+ border-radius: clamp(0.75rem, var(--radius), 1rem);
39
+ }