@vvedantb/ui 0.2.1 → 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 +69 -2
- package/package.json +12 -2
- package/shell/src/SessionsHoverCard.tsx +199 -0
- package/shell/src/index.ts +6 -0
- package/src/audio/audioFloor.ts +37 -0
- package/src/audio/index.ts +10 -0
- package/src/audio/playChime.ts +18 -0
- package/src/audio/playTone.ts +73 -0
- package/src/audio/useReplyChime.ts +62 -0
- package/src/index.ts +2 -0
- package/src/sonner.ts +2 -0
- package/src/ui/sonner-icons.tsx +94 -0
- package/src/ui/sonner.tsx +183 -34
- package/tokens/README.md +1 -1
- package/tokens/examples/toaster/Fixture.tsx +58 -0
- package/tokens/examples/toaster/README.md +18 -0
- package/tokens/examples/toaster/compat.html +12 -0
- package/tokens/examples/toaster/main-compat.tsx +5 -0
- package/tokens/examples/toaster/main-tokens.tsx +5 -0
- package/tokens/examples/toaster/package.json +14 -0
- package/tokens/examples/toaster/styles-compat.css +82 -0
- package/tokens/examples/toaster/styles-tokens.css +16 -0
- package/tokens/examples/toaster/tokens.html +12 -0
- package/tokens/examples/toaster/tsconfig.json +14 -0
- package/tokens/examples/toaster/vite.config.ts +12 -0
- package/tokens/src/css/compat-shadcn-rgb.css +7 -0
- package/tokens/src/css/utilities.css +5 -0
package/README.md
CHANGED
|
@@ -4,9 +4,11 @@ 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)
|
|
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
|
-
| `@vvedantb/ui/shell` | Router-free `SidebarHeader` and `
|
|
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. |
|
|
11
13
|
| `@vvedantb/ui/tokens` | `tokens.css`: colour, radius, shadow, ease and font tokens for Tailwind v4. |
|
|
12
14
|
| `@vvedantb/ui/tokens/css/*.css` | `index`, `base`, `dialog`, `modal`, `tabs`, `tabs-pills`, `utilities`, `scrollbar-thin`, `animations`, `compat-shadcn-rgb`. |
|
|
@@ -20,6 +22,71 @@ Shared React 19 web UI. Source-exported: no build step.
|
|
|
20
22
|
@source "../node_modules/@vvedantb/ui/src/**/*.{ts,tsx}";
|
|
21
23
|
```
|
|
22
24
|
|
|
25
|
+
## Reply chime
|
|
26
|
+
|
|
27
|
+
```tsx
|
|
28
|
+
import { playChime, useReplyChime } from "@vvedantb/ui";
|
|
29
|
+
|
|
30
|
+
useReplyChime({ enabled: soundOn, isStreaming, threadId });
|
|
31
|
+
```
|
|
32
|
+
|
|
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.
|
|
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.
|
|
38
|
+
|
|
39
|
+
## Sessions hover card
|
|
40
|
+
|
|
41
|
+
```tsx
|
|
42
|
+
import { SessionsHoverCard } from "@vvedantb/ui/shell";
|
|
43
|
+
|
|
44
|
+
<SessionsHoverCard
|
|
45
|
+
header="Session title"
|
|
46
|
+
loadContent={() => fetchPreview(id)}
|
|
47
|
+
footer="Updated today"
|
|
48
|
+
>
|
|
49
|
+
<button type="button">Row</button>
|
|
50
|
+
</SessionsHoverCard>
|
|
51
|
+
```
|
|
52
|
+
|
|
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/`.
|
|
54
|
+
|
|
55
|
+
## Toaster
|
|
56
|
+
|
|
57
|
+
```tsx
|
|
58
|
+
import { toast } from "sonner";
|
|
59
|
+
import { SonnerToaster } from "@vvedantb/ui"; // or "@vvedantb/ui/sonner"
|
|
60
|
+
|
|
61
|
+
<SonnerToaster
|
|
62
|
+
theme={theme}
|
|
63
|
+
toastRadius="surface"
|
|
64
|
+
toastIcons="filled24"
|
|
65
|
+
closeButtonStyle="hoverChip"
|
|
66
|
+
sonnerVars
|
|
67
|
+
descriptionMuted
|
|
68
|
+
/>;
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
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:
|
|
72
|
+
|
|
73
|
+
- `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.
|
|
74
|
+
- `icons`: merged per key. With `toastIcons="filled24"` the caller overrides single icons and keeps the rest.
|
|
75
|
+
- `style`: merged over the package style. `className` is appended.
|
|
76
|
+
|
|
77
|
+
Opt-in props (all default to the current look):
|
|
78
|
+
|
|
79
|
+
| Prop | Values | Effect |
|
|
80
|
+
| --- | --- | --- |
|
|
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. |
|
|
83
|
+
| `toastIcons` | `"default"`, `"filled24"` | Sonner icons or 24px filled success, info, warning and error icons, a matching loader and a 14px close cross. |
|
|
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. |
|
|
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`. |
|
|
86
|
+
| `descriptionMuted` | `false` (default), `true` | Description uses `text-muted-foreground!`, which beats sonner's dark-theme rule. |
|
|
87
|
+
|
|
88
|
+
Helpers: `createToasterClassNames`, `mergeToastClassNames`, `mergeToastOptions`, `mergeToasterStyle`, `filledToastIcons`. Fixture: `tokens/examples/toaster/`.
|
|
89
|
+
|
|
23
90
|
## Colour names in components
|
|
24
91
|
|
|
25
92
|
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.
|
|
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"
|
|
@@ -61,7 +69,9 @@
|
|
|
61
69
|
"tokens/src",
|
|
62
70
|
"tokens/examples",
|
|
63
71
|
"README.md",
|
|
64
|
-
"LICENSE"
|
|
72
|
+
"LICENSE",
|
|
73
|
+
"!**/*.test.ts",
|
|
74
|
+
"!**/*.test.tsx"
|
|
65
75
|
],
|
|
66
76
|
"sideEffects": [
|
|
67
77
|
"./src/ui/sonner.css"
|
|
@@ -0,0 +1,199 @@
|
|
|
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";
|
|
6
|
+
import { cn } from "../../src/utils/cn";
|
|
7
|
+
|
|
8
|
+
export type SessionsHoverCardContext = { open: boolean };
|
|
9
|
+
|
|
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
|
+
*/
|
|
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;
|
|
47
|
+
header?: ReactNode;
|
|
48
|
+
/**
|
|
49
|
+
* Body slot. A function runs only while the card is open, so it can start
|
|
50
|
+
* fetches for preview data.
|
|
51
|
+
*/
|
|
52
|
+
body?: ReactNode | ((ctx: SessionsHoverCardContext) => ReactNode);
|
|
53
|
+
/** Runs each time the card opens. Its result replaces `body`. */
|
|
54
|
+
loadContent?: () => Promise<ReactNode> | ReactNode;
|
|
55
|
+
/** Shown while `loadContent` is pending. */
|
|
56
|
+
loadingFallback?: ReactNode;
|
|
57
|
+
footer?: ReactNode;
|
|
58
|
+
/** Controlled open state. */
|
|
59
|
+
open?: boolean;
|
|
60
|
+
defaultOpen?: boolean;
|
|
61
|
+
onOpenChange?: (open: boolean) => void;
|
|
62
|
+
/** Delay before opening, in milliseconds. */
|
|
63
|
+
openDelay?: number;
|
|
64
|
+
/** Delay before closing, in milliseconds. */
|
|
65
|
+
closeDelay?: number;
|
|
66
|
+
side?: "top" | "right" | "bottom" | "left";
|
|
67
|
+
align?: "start" | "center" | "end";
|
|
68
|
+
sideOffset?: number;
|
|
69
|
+
/** Merged over the default `w-64 p-3` and the surface classes. */
|
|
70
|
+
className?: string;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
type LoadState = { status: "loading" } | { status: "ready"; node: ReactNode };
|
|
74
|
+
|
|
75
|
+
/** Mounts only while the card is open, so `loadContent` never runs while closed. */
|
|
76
|
+
function LoadedBody({
|
|
77
|
+
loadContent,
|
|
78
|
+
fallback,
|
|
79
|
+
}: {
|
|
80
|
+
loadContent: () => Promise<ReactNode> | ReactNode;
|
|
81
|
+
fallback: ReactNode;
|
|
82
|
+
}) {
|
|
83
|
+
const [state, setState] = useState<LoadState>({ status: "loading" });
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
let cancelled = false;
|
|
87
|
+
Promise.resolve()
|
|
88
|
+
.then(loadContent)
|
|
89
|
+
.then((node) => {
|
|
90
|
+
if (!cancelled) setState({ status: "ready", node });
|
|
91
|
+
})
|
|
92
|
+
.catch(() => {
|
|
93
|
+
if (!cancelled) setState({ status: "ready", node: null });
|
|
94
|
+
});
|
|
95
|
+
return () => {
|
|
96
|
+
cancelled = true;
|
|
97
|
+
};
|
|
98
|
+
// Run once per open. A new `loadContent` identity must not refetch.
|
|
99
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
100
|
+
}, []);
|
|
101
|
+
|
|
102
|
+
return <>{state.status === "ready" ? state.node : fallback}</>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Calls the render prop. Mounts only inside open content, never while closed. */
|
|
106
|
+
function RenderBody({
|
|
107
|
+
render,
|
|
108
|
+
open,
|
|
109
|
+
}: {
|
|
110
|
+
render: (ctx: SessionsHoverCardContext) => ReactNode;
|
|
111
|
+
open: boolean;
|
|
112
|
+
}) {
|
|
113
|
+
return <>{render({ open })}</>;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Hover card shell for list rows such as a sessions sidebar. It sets delay,
|
|
118
|
+
* position, width and padding, and gives header, body and footer slots. The
|
|
119
|
+
* app supplies the content.
|
|
120
|
+
*/
|
|
121
|
+
export const SessionsHoverCard = forwardRef<
|
|
122
|
+
ComponentRef<typeof HoverCardTrigger>,
|
|
123
|
+
SessionsHoverCardProps
|
|
124
|
+
>(function SessionsHoverCard({
|
|
125
|
+
children,
|
|
126
|
+
asChild = true,
|
|
127
|
+
portal = true,
|
|
128
|
+
surface = "overlay",
|
|
129
|
+
header,
|
|
130
|
+
body,
|
|
131
|
+
loadContent,
|
|
132
|
+
loadingFallback = null,
|
|
133
|
+
footer,
|
|
134
|
+
open: openProp,
|
|
135
|
+
defaultOpen = false,
|
|
136
|
+
onOpenChange,
|
|
137
|
+
openDelay = 400,
|
|
138
|
+
closeDelay = 150,
|
|
139
|
+
side = "right",
|
|
140
|
+
align = "start",
|
|
141
|
+
sideOffset = 8,
|
|
142
|
+
className,
|
|
143
|
+
...triggerProps
|
|
144
|
+
}, ref) {
|
|
145
|
+
const [openState, setOpenState] = useState(defaultOpen);
|
|
146
|
+
const open = openProp ?? openState;
|
|
147
|
+
|
|
148
|
+
const handleOpenChange = (next: boolean) => {
|
|
149
|
+
setOpenState(next);
|
|
150
|
+
onOpenChange?.(next);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const bodyNode = loadContent ? (
|
|
154
|
+
<LoadedBody loadContent={loadContent} fallback={loadingFallback} />
|
|
155
|
+
) : typeof body === "function" ? (
|
|
156
|
+
<RenderBody render={body} open={open} />
|
|
157
|
+
) : (
|
|
158
|
+
body
|
|
159
|
+
);
|
|
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
|
+
|
|
186
|
+
return (
|
|
187
|
+
<HoverCard
|
|
188
|
+
open={open}
|
|
189
|
+
onOpenChange={handleOpenChange}
|
|
190
|
+
openDelay={openDelay}
|
|
191
|
+
closeDelay={closeDelay}
|
|
192
|
+
>
|
|
193
|
+
<HoverCardTrigger ref={ref} asChild={asChild} {...triggerProps}>
|
|
194
|
+
{children}
|
|
195
|
+
</HoverCardTrigger>
|
|
196
|
+
{portal ? <HoverCardPrimitive.Portal>{content}</HoverCardPrimitive.Portal> : content}
|
|
197
|
+
</HoverCard>
|
|
198
|
+
);
|
|
199
|
+
});
|
package/shell/src/index.ts
CHANGED
|
@@ -4,3 +4,9 @@ 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
|
+
SessionsHoverCardSurface,
|
|
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
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
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";
|
|
9
|
+
export { useReplyChime, nextReplyChimeState } from "./useReplyChime";
|
|
10
|
+
export type { ReplyChimeInput, ReplyChimeWatch } from "./useReplyChime";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { playTone, type Tone } from "./playTone";
|
|
2
|
+
|
|
3
|
+
/** A soft rising interval (A5 then D6). The second note overlaps the first. */
|
|
4
|
+
const REPLY_CHIME: readonly Tone[] = [
|
|
5
|
+
{ frequency: 880, startOffsetS: 0, durationS: 0.28, peakGain: 0.14 },
|
|
6
|
+
{ frequency: 1174.66, startOffsetS: 0.09, durationS: 0.28, peakGain: 0.14 },
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Plays a soft rising two-note chime with WebAudio.
|
|
11
|
+
*
|
|
12
|
+
* The context is created on first use and shared. The call is a no-op on the
|
|
13
|
+
* server, without WebAudio, or while autoplay policy blocks audio. It shares
|
|
14
|
+
* the 1 second floor with every other package sound.
|
|
15
|
+
*/
|
|
16
|
+
export function playChime(): void {
|
|
17
|
+
playTone(REPLY_CHIME);
|
|
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
|
+
}
|
|
@@ -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
package/src/sonner.ts
ADDED
|
@@ -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,196 @@
|
|
|
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
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// `font
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// `data
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
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
|
+
/**
|
|
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";
|
|
36
|
+
/** `"default"` keeps sonner's icons. `"filled24"` uses 24px filled icons. */
|
|
37
|
+
toastIcons?: "default" | "filled24";
|
|
38
|
+
/**
|
|
39
|
+
* `"default"` keeps the text-style close button. `"hoverChip"` uses a round chip at the
|
|
40
|
+
* top-right, shown on hover (always on small screens) and enables sonner's `closeButton`
|
|
41
|
+
* unless the caller sets it.
|
|
42
|
+
*/
|
|
43
|
+
closeButtonStyle?: "default" | "hoverChip";
|
|
44
|
+
/** When true, set sonner's `--normal-*` variables from `--color-*` tokens and `font-family: inherit`. */
|
|
45
|
+
sonnerVars?: boolean;
|
|
46
|
+
/** When true, descriptions use `text-muted-foreground!` so sonner's dark rule cannot win. */
|
|
47
|
+
descriptionMuted?: boolean;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const hoverChipCloseButtonClassName =
|
|
51
|
+
"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";
|
|
52
|
+
|
|
53
|
+
const defaultCloseButtonClassName =
|
|
54
|
+
"rounded-lg bg-default px-2.5 py-1 text-xs font-medium text-foreground transition-colors hover:bg-default/78";
|
|
55
|
+
|
|
56
|
+
const toastRadiusClasses = {
|
|
57
|
+
lg: "rounded-lg",
|
|
58
|
+
surface: "rounded-surface",
|
|
59
|
+
control: "rounded-field",
|
|
60
|
+
} as const;
|
|
61
|
+
|
|
62
|
+
/** Default `classNames`, with the opt-in variants applied. No options gives the 0.2.1 defaults. */
|
|
63
|
+
function createToasterClassNames({
|
|
64
|
+
toastRadius = "lg",
|
|
65
|
+
density = "default",
|
|
66
|
+
closeButtonStyle = "default",
|
|
67
|
+
descriptionMuted = false,
|
|
68
|
+
}: Pick<
|
|
69
|
+
SonnerToasterOptions,
|
|
70
|
+
"toastRadius" | "density" | "closeButtonStyle" | "descriptionMuted"
|
|
71
|
+
> = {}): ToastClassNames {
|
|
72
|
+
const radius = toastRadiusClasses[toastRadius];
|
|
73
|
+
const compact = density === "compact";
|
|
74
|
+
const descriptionSize = compact ? "text-xs" : "text-[13px]";
|
|
75
|
+
return {
|
|
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`,
|
|
77
|
+
title: "font-medium leading-snug",
|
|
78
|
+
description: descriptionMuted
|
|
79
|
+
? `${descriptionSize} leading-snug text-muted-foreground!`
|
|
80
|
+
: `${descriptionSize} leading-snug text-muted-foreground`,
|
|
81
|
+
actionButton:
|
|
82
|
+
"rounded-lg bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90",
|
|
83
|
+
cancelButton:
|
|
84
|
+
"rounded-lg bg-surface-secondary px-2.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-surface-tertiary",
|
|
85
|
+
closeButton:
|
|
86
|
+
closeButtonStyle === "hoverChip"
|
|
87
|
+
? hoverChipCloseButtonClassName
|
|
88
|
+
: defaultCloseButtonClassName,
|
|
89
|
+
loader: "text-muted-foreground",
|
|
90
|
+
success: "[&_[data-icon]]:text-success",
|
|
91
|
+
error: "[&_[data-icon]]:text-danger",
|
|
92
|
+
warning: "[&_[data-icon]]:text-warning",
|
|
93
|
+
info: "[&_[data-icon]]:text-primary",
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Per key, joins the default class string and the caller string (defaults first, so callers win). */
|
|
98
|
+
function mergeToastClassNames(
|
|
99
|
+
defaults: ToastClassNames,
|
|
100
|
+
overrides?: ToastClassNames,
|
|
101
|
+
): ToastClassNames {
|
|
102
|
+
if (!overrides) return defaults;
|
|
103
|
+
const merged: Record<string, string | undefined> = { ...defaults };
|
|
104
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
105
|
+
if (!value) continue;
|
|
106
|
+
const base = merged[key];
|
|
107
|
+
merged[key] = base ? `${base} ${value}` : value;
|
|
108
|
+
}
|
|
109
|
+
return merged as ToastClassNames;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Sonner variables read from `--color-*` tokens. Work with `tokens.css` and the compat file. */
|
|
113
|
+
const sonnerVarsStyle: ToasterStyle = {
|
|
114
|
+
fontFamily: "inherit",
|
|
115
|
+
"--normal-bg": "var(--color-card)",
|
|
116
|
+
"--normal-border": "var(--color-border)",
|
|
117
|
+
"--normal-text": "var(--color-foreground)",
|
|
118
|
+
"--normal-bg-hover": "var(--color-muted)",
|
|
119
|
+
"--normal-border-hover": "var(--color-border)",
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/** `{ ...defaults, ...overrides }`. The package adds no style keys unless `sonnerVars` is on. */
|
|
123
|
+
function mergeToasterStyle(
|
|
124
|
+
defaults: CSSProperties | undefined,
|
|
125
|
+
overrides: CSSProperties | undefined,
|
|
126
|
+
): CSSProperties | undefined {
|
|
127
|
+
if (!defaults) return overrides;
|
|
128
|
+
return overrides ? { ...defaults, ...overrides } : defaults;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Package defaults first. Caller wins per field, except `classNames`, which merge. */
|
|
132
|
+
function mergeToastOptions(
|
|
133
|
+
defaults: NonNullable<ToasterProps["toastOptions"]>,
|
|
134
|
+
overrides?: ToasterProps["toastOptions"],
|
|
135
|
+
): NonNullable<ToasterProps["toastOptions"]> {
|
|
136
|
+
const { classNames, ...rest } = overrides ?? {};
|
|
137
|
+
return {
|
|
138
|
+
...defaults,
|
|
139
|
+
...rest,
|
|
140
|
+
classNames: mergeToastClassNames(defaults.classNames ?? {}, classNames),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function SonnerToaster({
|
|
145
|
+
toastRadius,
|
|
146
|
+
density,
|
|
147
|
+
toastIcons = "default",
|
|
148
|
+
closeButtonStyle = "default",
|
|
149
|
+
sonnerVars = false,
|
|
150
|
+
descriptionMuted,
|
|
151
|
+
toastOptions,
|
|
152
|
+
icons,
|
|
153
|
+
style,
|
|
154
|
+
className,
|
|
155
|
+
closeButton,
|
|
156
|
+
...props
|
|
157
|
+
}: ToasterProps & SonnerToasterOptions) {
|
|
158
|
+
const mergedIcons =
|
|
159
|
+
toastIcons === "filled24" ? { ...filledToastIcons, ...icons } : icons;
|
|
16
160
|
return (
|
|
17
161
|
<Toaster
|
|
18
162
|
expand={false}
|
|
19
163
|
visibleToasts={4}
|
|
20
164
|
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
165
|
{...props}
|
|
166
|
+
closeButton={closeButton ?? (closeButtonStyle === "hoverChip" ? true : undefined)}
|
|
167
|
+
className={className ? `toaster group !font-sans ${className}` : "toaster group !font-sans"}
|
|
168
|
+
icons={mergedIcons}
|
|
169
|
+
style={mergeToasterStyle(sonnerVars ? sonnerVarsStyle : undefined, style)}
|
|
170
|
+
toastOptions={mergeToastOptions(
|
|
171
|
+
{
|
|
172
|
+
unstyled: true,
|
|
173
|
+
classNames: createToasterClassNames({
|
|
174
|
+
toastRadius,
|
|
175
|
+
density,
|
|
176
|
+
closeButtonStyle,
|
|
177
|
+
descriptionMuted,
|
|
178
|
+
}),
|
|
179
|
+
},
|
|
180
|
+
toastOptions,
|
|
181
|
+
)}
|
|
43
182
|
/>
|
|
44
183
|
);
|
|
45
184
|
}
|
|
46
185
|
|
|
47
|
-
export {
|
|
186
|
+
export {
|
|
187
|
+
SonnerToaster,
|
|
188
|
+
createToasterClassNames,
|
|
189
|
+
hoverChipCloseButtonClassName,
|
|
190
|
+
mergeToastClassNames,
|
|
191
|
+
mergeToastOptions,
|
|
192
|
+
mergeToasterStyle,
|
|
193
|
+
sonnerVarsStyle,
|
|
194
|
+
};
|
|
195
|
+
export { filledToastIcons } from "./sonner-icons";
|
|
196
|
+
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
|
|
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,58 @@
|
|
|
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
|
+
const compact =
|
|
11
|
+
typeof window !== "undefined" &&
|
|
12
|
+
new URLSearchParams(window.location.search).get("variant") === "compact";
|
|
13
|
+
|
|
14
|
+
function fire() {
|
|
15
|
+
toast.success("Changes saved", {
|
|
16
|
+
description: "Your settings were updated and will apply to new sessions.",
|
|
17
|
+
duration: Infinity,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function Fixture() {
|
|
22
|
+
const [dark, setDark] = useState(initialDark);
|
|
23
|
+
|
|
24
|
+
// Sonner portals toasts onto `document.body`, so `.dark` must live on `<html>`
|
|
25
|
+
// (not an inner wrapper) for token / Tailwind dark styles to reach them.
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
document.documentElement.classList.toggle("dark", dark);
|
|
28
|
+
return () => {
|
|
29
|
+
document.documentElement.classList.remove("dark");
|
|
30
|
+
};
|
|
31
|
+
}, [dark]);
|
|
32
|
+
|
|
33
|
+
// Fire one sample toast on load so a screenshot captures it.
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
const id = window.setTimeout(fire, 150);
|
|
36
|
+
return () => window.clearTimeout(id);
|
|
37
|
+
}, []);
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<>
|
|
41
|
+
<main className="min-h-screen bg-background p-8 text-foreground">
|
|
42
|
+
<div className="flex gap-3">
|
|
43
|
+
<Button onClick={() => setDark((d) => !d)}>Toggle theme</Button>
|
|
44
|
+
<Button onClick={fire}>Show toast</Button>
|
|
45
|
+
</div>
|
|
46
|
+
</main>
|
|
47
|
+
<SonnerToaster
|
|
48
|
+
theme={dark ? "dark" : "light"}
|
|
49
|
+
toastRadius={compact ? "control" : "surface"}
|
|
50
|
+
density={compact ? "compact" : "default"}
|
|
51
|
+
toastIcons="filled24"
|
|
52
|
+
closeButtonStyle="hoverChip"
|
|
53
|
+
sonnerVars
|
|
54
|
+
descriptionMuted
|
|
55
|
+
/>
|
|
56
|
+
</>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -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 `?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.
|
|
@@ -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,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));
|