@vvedantb/ui 0.2.2 → 0.2.3

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
@@ -5,6 +5,8 @@ Shared React 19 web UI. Source-exported: no build step.
5
5
  | Import | What it is |
6
6
  | --- | --- |
7
7
  | `@vvedantb/ui` | Primitives (Radix-based), AI elements, `playChime` and `useReplyChime`. |
8
+ | `@vvedantb/ui/sonner` | `SonnerToaster`, its helpers and `toast`. |
9
+ | `@vvedantb/ui/audio` | `playChime`, `useReplyChime`, `playTone` and the shared audio floor. |
8
10
  | `@vvedantb/ui/cn` | `cn` class helper (clsx + tailwind-merge). |
9
11
  | `@vvedantb/ui/shell` | Router-free `SidebarHeader`, `PillTabs` and `SessionsHoverCard`. |
10
12
  | `@vvedantb/ui/app` | Alt-click confirm skip, update watcher, settings chrome, `useStableAuth`. See `app/README.md` in the repository. |
@@ -29,6 +31,9 @@ useReplyChime({ enabled: soundOn, isStreaming, threadId });
29
31
  ```
30
32
 
31
33
  - `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.
34
+ - `playTone(tone | tones)`: plays `{ frequency, startOffsetS?, durationS?, peakGain?, type? }` through the same floor.
35
+ - Audio floor: every package sound shares one 1 second minimum gap. Route your own sounds through it with `withAudioFloor(() => play())` or `if (tryAcquireAudioFloor()) play()`.
36
+ - These are also available from `@vvedantb/ui/audio`, which does not load the rest of the root barrel.
32
37
  - `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
38
 
34
39
  ## Sessions hover card
@@ -45,13 +50,13 @@ import { SessionsHoverCard } from "@vvedantb/ui/shell";
45
50
  </SessionsHoverCard>
46
51
  ```
47
52
 
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/`.
53
+ `children` is the trigger. It is rendered with `asChild` by default, so it keeps its own handlers (such as a context menu). Extra props and the ref go to it. Use `asChild={false}` for a default trigger element. The content portals to `document.body` by default, so `overflow:hidden` ancestors do not clip it. Pass `portal={false}` to opt out. `surface` is `"overlay"` (default), `"surface"` (`rounded-surface`) or `"glass"` (`glass-panel-strong`, `rounded-surface`). `className` merges over the surface without `!`. 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
54
 
50
55
  ## Toaster
51
56
 
52
57
  ```tsx
53
58
  import { toast } from "sonner";
54
- import { SonnerToaster } from "@vvedantb/ui";
59
+ import { SonnerToaster } from "@vvedantb/ui"; // or "@vvedantb/ui/sonner"
55
60
 
56
61
  <SonnerToaster
57
62
  theme={theme}
@@ -73,7 +78,8 @@ Opt-in props (all default to the current look):
73
78
 
74
79
  | Prop | Values | Effect |
75
80
  | --- | --- | --- |
76
- | `toastRadius` | `"lg"` (default), `"surface"` | `rounded-lg` or `rounded-surface` (from `tokens/css/utilities.css`). |
81
+ | `toastRadius` | `"lg"` (default), `"surface"`, `"control"` | `rounded-lg`, `rounded-surface` (from `tokens/css/utilities.css`) or `rounded-field` (the input radius). |
82
+ | `density` | `"default"`, `"compact"` | Current padding and text, or `gap-2 px-3 py-2 text-xs` with `text-xs` descriptions. |
77
83
  | `toastIcons` | `"default"`, `"filled24"` | Sonner icons or 24px filled success, info, warning and error icons, a matching loader and a 14px close cross. |
78
84
  | `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
85
  | `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`. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vvedantb/ui",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
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",
@@ -21,6 +21,14 @@
21
21
  "types": "./src/utils/cn.ts",
22
22
  "default": "./src/utils/cn.ts"
23
23
  },
24
+ "./sonner": {
25
+ "types": "./src/sonner.ts",
26
+ "default": "./src/sonner.ts"
27
+ },
28
+ "./audio": {
29
+ "types": "./src/audio/index.ts",
30
+ "default": "./src/audio/index.ts"
31
+ },
24
32
  "./shell": {
25
33
  "types": "./shell/src/index.ts",
26
34
  "default": "./shell/src/index.ts"
@@ -1,17 +1,49 @@
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";
1
+ import { forwardRef, useEffect, useState } from "react";
2
+ import type { ComponentPropsWithoutRef, ComponentRef, ReactNode } from "react";
3
+ import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
4
+ import { HoverCard, HoverCardTrigger } from "../../src/ui/hover-card";
5
+ import { floatingSurfaceAnimateClass, floatingSurfaceClass } from "../../src/ui/_menu-classes";
8
6
  import { cn } from "../../src/utils/cn";
9
7
 
10
8
  export type SessionsHoverCardContext = { open: boolean };
11
9
 
12
- export type SessionsHoverCardProps = {
13
- /** The element that triggers the card, such as a list row. Rendered with `asChild`. */
10
+ export type SessionsHoverCardSurface = "overlay" | "surface" | "glass";
11
+
12
+ /**
13
+ * Surface classes. Only `overlay` carries the shared floating surface, so the
14
+ * others need no `!` overrides, and `className` merges over all of them.
15
+ */
16
+ const surfaceClasses: Record<SessionsHoverCardSurface, string> = {
17
+ overlay: floatingSurfaceClass,
18
+ surface: "rounded-surface bg-overlay text-overlay-foreground smooth-shadow-ring-lg",
19
+ glass: "rounded-surface glass-panel-strong text-foreground smooth-shadow-ring-lg",
20
+ };
21
+
22
+ export type SessionsHoverCardProps = Omit<
23
+ ComponentPropsWithoutRef<typeof HoverCardTrigger>,
24
+ "className" | "children"
25
+ > & {
26
+ /**
27
+ * The trigger. With `asChild` (default) it is the element that triggers the
28
+ * card, such as a list row. Trigger props and the ref go to that element,
29
+ * which keeps its own handlers. With `asChild={false}` the children render
30
+ * inside a default trigger element.
31
+ */
14
32
  children: ReactNode;
33
+ /** Default `true`. */
34
+ asChild?: boolean;
35
+ /**
36
+ * Render the content in a portal on `document.body`, so `overflow:hidden`
37
+ * ancestors do not clip it. Default `true`. Use `false` to keep the content
38
+ * in place in the DOM.
39
+ */
40
+ portal?: boolean;
41
+ /**
42
+ * `"overlay"` (default): floating overlay, `rounded-lg`. `"surface"`: same
43
+ * fill with `rounded-surface`. `"glass"`: `glass-panel-strong` with
44
+ * `rounded-surface`.
45
+ */
46
+ surface?: SessionsHoverCardSurface;
15
47
  header?: ReactNode;
16
48
  /**
17
49
  * Body slot. A function runs only while the card is open, so it can start
@@ -34,7 +66,7 @@ export type SessionsHoverCardProps = {
34
66
  side?: "top" | "right" | "bottom" | "left";
35
67
  align?: "start" | "center" | "end";
36
68
  sideOffset?: number;
37
- /** Merged over the default `w-64 p-3`. */
69
+ /** Merged over the default `w-64 p-3` and the surface classes. */
38
70
  className?: string;
39
71
  };
40
72
 
@@ -86,8 +118,14 @@ function RenderBody({
86
118
  * position, width and padding, and gives header, body and footer slots. The
87
119
  * app supplies the content.
88
120
  */
89
- export function SessionsHoverCard({
121
+ export const SessionsHoverCard = forwardRef<
122
+ ComponentRef<typeof HoverCardTrigger>,
123
+ SessionsHoverCardProps
124
+ >(function SessionsHoverCard({
90
125
  children,
126
+ asChild = true,
127
+ portal = true,
128
+ surface = "overlay",
91
129
  header,
92
130
  body,
93
131
  loadContent,
@@ -102,7 +140,8 @@ export function SessionsHoverCard({
102
140
  align = "start",
103
141
  sideOffset = 8,
104
142
  className,
105
- }: SessionsHoverCardProps) {
143
+ ...triggerProps
144
+ }, ref) {
106
145
  const [openState, setOpenState] = useState(defaultOpen);
107
146
  const open = openProp ?? openState;
108
147
 
@@ -119,6 +158,31 @@ export function SessionsHoverCard({
119
158
  body
120
159
  );
121
160
 
161
+ const content = (
162
+ <HoverCardPrimitive.Content
163
+ side={side}
164
+ align={align}
165
+ sideOffset={sideOffset}
166
+ className={cn(
167
+ "z-50 outline-none max-sm:max-h-[85dvh] max-sm:max-w-[calc(100vw-2rem)] max-sm:overflow-y-auto",
168
+ surfaceClasses[surface],
169
+ floatingSurfaceAnimateClass,
170
+ "w-64 p-3",
171
+ className,
172
+ )}
173
+ >
174
+ {header ? (
175
+ <div className="mb-2 text-sm font-medium text-foreground">{header}</div>
176
+ ) : null}
177
+ {bodyNode != null ? (
178
+ <div className="text-sm text-muted-foreground">{bodyNode}</div>
179
+ ) : null}
180
+ {footer ? (
181
+ <div className="mt-2 text-xs text-muted-foreground">{footer}</div>
182
+ ) : null}
183
+ </HoverCardPrimitive.Content>
184
+ );
185
+
122
186
  return (
123
187
  <HoverCard
124
188
  open={open}
@@ -126,23 +190,10 @@ export function SessionsHoverCard({
126
190
  openDelay={openDelay}
127
191
  closeDelay={closeDelay}
128
192
  >
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>
193
+ <HoverCardTrigger ref={ref} asChild={asChild} {...triggerProps}>
194
+ {children}
195
+ </HoverCardTrigger>
196
+ {portal ? <HoverCardPrimitive.Portal>{content}</HoverCardPrimitive.Portal> : content}
146
197
  </HoverCard>
147
198
  );
148
- }
199
+ });
@@ -8,4 +8,5 @@ export { SessionsHoverCard } from "./SessionsHoverCard.js";
8
8
  export type {
9
9
  SessionsHoverCardContext,
10
10
  SessionsHoverCardProps,
11
+ SessionsHoverCardSurface,
11
12
  } from "./SessionsHoverCard.js";
@@ -0,0 +1,37 @@
1
+ /** Calls closer together than this are dropped, so overlapping events sound once. */
2
+ export const AUDIO_FLOOR_MIN_GAP_S = 1;
3
+
4
+ let lastAcquiredAtS: number | null = null;
5
+
6
+ function nowS(): number {
7
+ return performance.now() / 1000;
8
+ }
9
+
10
+ /**
11
+ * Takes the shared audio floor. Returns `true` when at least 1 second has
12
+ * passed since the last sound that took it, and starts a new gap. Returns
13
+ * `false` otherwise, and the caller should stay silent.
14
+ *
15
+ * Every package sound (`playChime`, `playTone`) goes through this one floor.
16
+ * Apps can route their own sounds through it too.
17
+ */
18
+ export function tryAcquireAudioFloor(): boolean {
19
+ const now = nowS();
20
+ if (lastAcquiredAtS !== null && now - lastAcquiredAtS < AUDIO_FLOOR_MIN_GAP_S) {
21
+ return false;
22
+ }
23
+ lastAcquiredAtS = now;
24
+ return true;
25
+ }
26
+
27
+ /** Runs `play` only if the shared floor is free. Returns whether it ran. */
28
+ export function withAudioFloor(play: () => void): boolean {
29
+ if (!tryAcquireAudioFloor()) return false;
30
+ play();
31
+ return true;
32
+ }
33
+
34
+ /** Clears the floor. For tests. */
35
+ export function resetAudioFloor(): void {
36
+ lastAcquiredAtS = null;
37
+ }
@@ -1,3 +1,10 @@
1
1
  export { playChime } from "./playChime";
2
+ export { playTone } from "./playTone";
3
+ export type { Tone } from "./playTone";
4
+ export {
5
+ AUDIO_FLOOR_MIN_GAP_S,
6
+ tryAcquireAudioFloor,
7
+ withAudioFloor,
8
+ } from "./audioFloor";
2
9
  export { useReplyChime, nextReplyChimeState } from "./useReplyChime";
3
10
  export type { ReplyChimeInput, ReplyChimeWatch } from "./useReplyChime";
@@ -1,9 +1,4 @@
1
- type Tone = {
2
- frequency: number;
3
- startOffsetS: number;
4
- durationS: number;
5
- peakGain: number;
6
- };
1
+ import { playTone, type Tone } from "./playTone";
7
2
 
8
3
  /** A soft rising interval (A5 then D6). The second note overlaps the first. */
9
4
  const REPLY_CHIME: readonly Tone[] = [
@@ -11,66 +6,13 @@ const REPLY_CHIME: readonly Tone[] = [
11
6
  { frequency: 1174.66, startOffsetS: 0.09, durationS: 0.28, peakGain: 0.14 },
12
7
  ];
13
8
 
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
9
  /**
35
10
  * Plays a soft rising two-note chime with WebAudio.
36
11
  *
37
12
  * 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.
13
+ * server, without WebAudio, or while autoplay policy blocks audio. It shares
14
+ * the 1 second floor with every other package sound.
39
15
  */
40
16
  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
- }
17
+ playTone(REPLY_CHIME);
76
18
  }
@@ -0,0 +1,73 @@
1
+ import { tryAcquireAudioFloor } from "./audioFloor";
2
+
3
+ export type Tone = {
4
+ /** Pitch in Hz. */
5
+ frequency: number;
6
+ /** Delay from the start of the call, in seconds. Default 0. */
7
+ startOffsetS?: number;
8
+ /** Length in seconds. Default 0.28. */
9
+ durationS?: number;
10
+ /** Peak gain from 0 to 1. Default 0.14. */
11
+ peakGain?: number;
12
+ /** Default "sine". */
13
+ type?: OscillatorType;
14
+ };
15
+
16
+ /** Long enough to avoid a click on attack. */
17
+ const ATTACK_S = 0.012;
18
+
19
+ let sharedContext: AudioContext | null = null;
20
+
21
+ function getAudioContext(): AudioContext | null {
22
+ if (sharedContext) return sharedContext;
23
+ if (typeof window === "undefined" || !window.AudioContext) return null;
24
+ try {
25
+ sharedContext = new window.AudioContext();
26
+ } catch {
27
+ return null;
28
+ }
29
+ return sharedContext;
30
+ }
31
+
32
+ /**
33
+ * Plays one tone or a sequence of overlapping tones with WebAudio, through the
34
+ * shared 1 second floor (see `tryAcquireAudioFloor`). The context is created on
35
+ * first use and shared. The call is a no-op on the server, without WebAudio,
36
+ * or while autoplay policy blocks audio.
37
+ */
38
+ export function playTone(tones: Tone | readonly Tone[]): void {
39
+ const context = getAudioContext();
40
+ if (!context) return;
41
+
42
+ if (context.state === "suspended") {
43
+ try {
44
+ context.resume().catch(() => undefined);
45
+ } catch {
46
+ return;
47
+ }
48
+ }
49
+
50
+ if (!tryAcquireAudioFloor()) return;
51
+
52
+ try {
53
+ const start = context.currentTime;
54
+ for (const tone of Array.isArray(tones) ? tones : [tones as Tone]) {
55
+ const oscillator = context.createOscillator();
56
+ const envelope = context.createGain();
57
+ oscillator.type = tone.type ?? "sine";
58
+ oscillator.frequency.value = tone.frequency;
59
+
60
+ const toneStart = start + (tone.startOffsetS ?? 0);
61
+ const toneEnd = toneStart + (tone.durationS ?? 0.28);
62
+ envelope.gain.setValueAtTime(0, toneStart);
63
+ envelope.gain.linearRampToValueAtTime(tone.peakGain ?? 0.14, toneStart + ATTACK_S);
64
+ envelope.gain.exponentialRampToValueAtTime(0.0001, toneEnd);
65
+
66
+ oscillator.connect(envelope).connect(context.destination);
67
+ oscillator.start(toneStart);
68
+ oscillator.stop(toneEnd);
69
+ }
70
+ } catch {
71
+ // Audio is optional. Stay silent on any failure.
72
+ }
73
+ }
package/src/sonner.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from "./ui/sonner";
2
+ export { toast } from "sonner";
package/src/ui/sonner.tsx CHANGED
@@ -26,8 +26,13 @@ type ToastClassNames = NonNullable<
26
26
  type ToasterStyle = CSSProperties & Record<`--${string}`, string>;
27
27
 
28
28
  type SonnerToasterOptions = {
29
- /** `"lg"` (default) uses `rounded-lg`. `"surface"` uses `rounded-surface`. */
30
- toastRadius?: "lg" | "surface";
29
+ /**
30
+ * `"lg"` (default) uses `rounded-lg`. `"surface"` uses `rounded-surface`.
31
+ * `"control"` uses `rounded-field`, the radius of inputs and selects.
32
+ */
33
+ toastRadius?: "lg" | "surface" | "control";
34
+ /** `"default"` keeps the current padding and text size. `"compact"` tightens padding, gap and text. */
35
+ density?: "default" | "compact";
31
36
  /** `"default"` keeps sonner's icons. `"filled24"` uses 24px filled icons. */
32
37
  toastIcons?: "default" | "filled24";
33
38
  /**
@@ -48,22 +53,31 @@ const hoverChipCloseButtonClassName =
48
53
  const defaultCloseButtonClassName =
49
54
  "rounded-lg bg-default px-2.5 py-1 text-xs font-medium text-foreground transition-colors hover:bg-default/78";
50
55
 
56
+ const toastRadiusClasses = {
57
+ lg: "rounded-lg",
58
+ surface: "rounded-surface",
59
+ control: "rounded-field",
60
+ } as const;
61
+
51
62
  /** Default `classNames`, with the opt-in variants applied. No options gives the 0.2.1 defaults. */
52
63
  function createToasterClassNames({
53
64
  toastRadius = "lg",
65
+ density = "default",
54
66
  closeButtonStyle = "default",
55
67
  descriptionMuted = false,
56
68
  }: Pick<
57
69
  SonnerToasterOptions,
58
- "toastRadius" | "closeButtonStyle" | "descriptionMuted"
70
+ "toastRadius" | "density" | "closeButtonStyle" | "descriptionMuted"
59
71
  > = {}): ToastClassNames {
60
- const radius = toastRadius === "surface" ? "rounded-surface" : "rounded-lg";
72
+ const radius = toastRadiusClasses[toastRadius];
73
+ const compact = density === "compact";
74
+ const descriptionSize = compact ? "text-xs" : "text-[13px]";
61
75
  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`,
76
+ toast: `group toast glass-panel-strong flex w-full items-center ${compact ? "gap-2" : "gap-3"} ${radius} ${compact ? "px-3 py-2 text-xs" : "px-4 py-3 text-sm"} text-foreground smooth-shadow-ring-md`,
63
77
  title: "font-medium leading-snug",
64
78
  description: descriptionMuted
65
- ? "text-[13px] leading-snug text-muted-foreground!"
66
- : "text-[13px] leading-snug text-muted-foreground",
79
+ ? `${descriptionSize} leading-snug text-muted-foreground!`
80
+ : `${descriptionSize} leading-snug text-muted-foreground`,
67
81
  actionButton:
68
82
  "rounded-lg bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90",
69
83
  cancelButton:
@@ -129,6 +143,7 @@ function mergeToastOptions(
129
143
 
130
144
  function SonnerToaster({
131
145
  toastRadius,
146
+ density,
132
147
  toastIcons = "default",
133
148
  closeButtonStyle = "default",
134
149
  sonnerVars = false,
@@ -157,6 +172,7 @@ function SonnerToaster({
157
172
  unstyled: true,
158
173
  classNames: createToasterClassNames({
159
174
  toastRadius,
175
+ density,
160
176
  closeButtonStyle,
161
177
  descriptionMuted,
162
178
  }),
@@ -7,6 +7,10 @@ function initialDark(): boolean {
7
7
  return new URLSearchParams(window.location.search).get("theme") === "dark";
8
8
  }
9
9
 
10
+ const compact =
11
+ typeof window !== "undefined" &&
12
+ new URLSearchParams(window.location.search).get("variant") === "compact";
13
+
10
14
  function fire() {
11
15
  toast.success("Changes saved", {
12
16
  description: "Your settings were updated and will apply to new sessions.",
@@ -42,7 +46,8 @@ export function Fixture() {
42
46
  </main>
43
47
  <SonnerToaster
44
48
  theme={dark ? "dark" : "light"}
45
- toastRadius="surface"
49
+ toastRadius={compact ? "control" : "surface"}
50
+ density={compact ? "compact" : "default"}
46
51
  toastIcons="filled24"
47
52
  closeButtonStyle="hoverChip"
48
53
  sonnerVars
@@ -15,4 +15,4 @@ Run it from this folder (it is not a workspace member, and React resolves from t
15
15
  pnpm install --ignore-workspace && pnpm dev
16
16
  ```
17
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.
18
+ Open the printed URL plus `/tokens.html` or `/compat.html`. Add `?variant=compact` for the compact density with `toastRadius="control"`. Add `?theme=dark` for dark mode (combine with `&`), or use "Toggle theme". Hover the toast to see the close chip. `Fixture.tsx` is not part of the package typecheck.