@vvedantb/ui 0.35.0 → 0.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -0
- package/package.json +10 -2
- package/src/client.ts +3 -0
- package/src/icons/animated/animated-bell-icon.tsx +68 -0
- package/src/icons/animated/animated-counter.tsx +45 -0
- package/src/icons/animated/animated-key-icon.tsx +79 -0
- package/src/icons/animated/animated-notification-icon.tsx +202 -0
- package/src/icons/animated/animated-progress.tsx +74 -0
- package/src/icons/animated/animated-status-dot.tsx +98 -0
- package/src/icons/animated/index.ts +22 -0
- package/src/icons/animated/morphing-menu-icon.tsx +71 -0
- package/src/icons/animated/morphing-mic-icon.tsx +52 -0
- package/src/icons/animated/pulsing-rings.tsx +50 -0
- package/src/index.ts +3 -0
- package/src/logos/chrome-icon.tsx +29 -0
- package/src/logos/claude-icon.tsx +13 -0
- package/src/logos/convex-icon.tsx +21 -0
- package/src/logos/cursor-icon.tsx +10 -0
- package/src/logos/dropbox-icon.tsx +14 -0
- package/src/logos/figma-icon.tsx +26 -0
- package/src/logos/github-icon.tsx +10 -0
- package/src/logos/gmail-icon.tsx +34 -0
- package/src/logos/google-drive-icon.tsx +33 -0
- package/src/logos/index.ts +19 -0
- package/src/logos/linear-icon.tsx +13 -0
- package/src/logos/logo-svg.tsx +32 -0
- package/src/logos/notion-icon.tsx +19 -0
- package/src/logos/onedrive-icon.tsx +165 -0
- package/src/logos/sharepoint-icon.tsx +222 -0
- package/src/logos/slack-icon.tsx +25 -0
- package/src/logos/vercel-icon.tsx +10 -0
package/README.md
CHANGED
|
@@ -7,6 +7,8 @@ Shared React 19 web UI. Source-exported: no build step.
|
|
|
7
7
|
| `@vvedantb/ui` | The main JavaScript entry. Primitives (Radix-based), AI elements, audio (`playChime`, `useReplyChime`, `playTone`), `toast` and `SonnerToaster`, the composer (`InputArea`, deprecated 0.3 `Composer`), shell (`SidebarHeader`, `SidebarUpgradeCard`, `PillTabs`, `SessionsHoverCard`, and the app shell: `AppShell`, `AppSidebar`, `SidebarRail`, `RailTile`, `SidebarPanel`, `SidebarAccountMenu`, `SignOutDialog` and related helpers), app helpers (confirm skip, update watcher, settings chrome, `useStableAuth`, and from 0.8.0 `createThemeProvider`, `createPageTitleContext`, `PageMotionProvider` and `formatCompactRelativeTime`), marketing pieces from 0.9.0 (`createLandingKit` and the `Landing*` parts, `SiteFooter`, `PricingModal`), animated nav icons from 0.11.0 (`MemoryIcon`, `BoardIcon`, ... and `NavIconBase`; `SettingsIcon` `variant` from 0.12.0), artifact viewers from 0.13.0 (`ArtifactFrame`, `ArtifactList`, `ArtifactViewer`, ...), date pickers from 0.14.0 (`Calendar`, `DatePicker`, `DateTimePicker`, `MonthPicker`), `cn`, and the native tokens `tokens` and `Tokens`. See `app/README.md` in the repository for the app helpers. |
|
|
8
8
|
| `@vvedantb/ui/client` | The root without `@vvedantb/server`, convex or Clerk, for Chrome extensions (0.28.0). Leaves out the artifact viewers, `ConvexClerkProvider`, `useStableAuth`, `createUseStableAuth`, `resetStableAuthSession` and `useEnsureUser`. See [Chrome extensions](#chrome-extensions-0280). |
|
|
9
9
|
| `@vvedantb/ui/brand` | App icons (`Icon*`, `APP_ICONS`), `AppWordmark`, deprecated `AppMark`, raw SVG strings and `FAMILY_APPS`. Also in the root and `/client`. |
|
|
10
|
+
| `@vvedantb/ui/logos` | Third-party brand logos (`GitHubIcon`, `GoogleDriveIcon`, `SlackIcon`, `ClaudeIcon`, `VercelIcon`, ...) and `LogoProps` (0.36.0). Also in the root and `/client`. See [Third-party logos](#third-party-logos-0360). |
|
|
11
|
+
| `@vvedantb/ui/icons/animated` | Motion-driven animated SVG icons (`AnimatedNotificationIcon`, `MorphingMenuIcon`, `AnimatedBellIcon`, ...) (0.36.0). Not in the root or `/client`. Needs the optional `motion` peer. See [Animated SVG icons](#animated-svg-icons-0360). |
|
|
10
12
|
| `@vvedantb/ui/tokens` | `tokens.css`: colour, radius, shadow, ease and font tokens for Tailwind v4. CSS. |
|
|
11
13
|
| `@vvedantb/ui/tokens/tokens.css` | Same file as `@vvedantb/ui/tokens`. CSS. |
|
|
12
14
|
| `@vvedantb/ui/tokens/css/*.css` | `index`, `base`, `dialog`, `modal`, `tabs`, `tabs-pills`, `utilities`, `scrollbar-thin`, `clear-input`, `motion`, `animations`, `nav-icons`, `compat-shadcn-rgb`, `border-beam` (opt-in, for `BorderBeam`), `markdown` (opt-in, for `Markdown`). CSS. `tokens.css` imports `scrollbar-thin`, `clear-input` and `motion`. |
|
|
@@ -1155,6 +1157,47 @@ Props (type `IconProps`; all optional):
|
|
|
1155
1157
|
|
|
1156
1158
|
Other SVG props (`id`, `data-*`, handlers, `ref`) go to the svg. On group hover the svg scales to 1.15 (the first six icons, spring ease) or 1.12 (the rest). `NavIconBase` is the shared svg wrapper, for icons drawn in the same style. The stylesheet has no reduced-motion override.
|
|
1157
1159
|
|
|
1160
|
+
## Third-party logos (0.36.0)
|
|
1161
|
+
|
|
1162
|
+
Full-colour vendor logos, separate from the family marks in `@vvedantb/ui/brand`. Import from `@vvedantb/ui/logos` (also in the root and `/client`):
|
|
1163
|
+
|
|
1164
|
+
```tsx
|
|
1165
|
+
import { GitHubIcon, GoogleDriveIcon, SlackIcon } from "@vvedantb/ui/logos";
|
|
1166
|
+
|
|
1167
|
+
<GoogleDriveIcon /> // 24px, aria-hidden
|
|
1168
|
+
<SlackIcon size={16} title="Slack" /> // role="img", aria-label and <title>
|
|
1169
|
+
<GitHubIcon className="text-foreground" /> // monochrome marks follow currentColor
|
|
1170
|
+
```
|
|
1171
|
+
|
|
1172
|
+
| Logo | Colour |
|
|
1173
|
+
| --- | --- |
|
|
1174
|
+
| `ChromeIcon`, `ConvexIcon`, `DropboxIcon`, `FigmaIcon`, `GmailIcon`, `GoogleDriveIcon`, `LinearIcon`, `NotionIcon`, `OneDriveIcon`, `SharePointIcon`, `SlackIcon` | Brand colours |
|
|
1175
|
+
| `ClaudeIcon`, `CursorIcon`, `GitHubIcon`, `VercelIcon` | `currentColor` |
|
|
1176
|
+
|
|
1177
|
+
Props (`LogoProps`): `size` (number or CSS length, default `24`, sets width and height), `title` (accessible name: the svg becomes `role="img"` with `aria-label` and a `<title>`; without `title` or `aria-label` it is `aria-hidden`), and any other SVG prop (`className`, `style`, `data-*`, `ref`). `OneDriveIcon` and `SharePointIcon` use `useId` for their gradient ids, so several on one page do not clash. Paths are the vendors' own: do not recolour the full-colour marks. For the AI provider marks in model pickers, keep using `ProviderIcon`.
|
|
1178
|
+
|
|
1179
|
+
## Animated SVG icons (0.36.0)
|
|
1180
|
+
|
|
1181
|
+
Motion-driven icons, on their own entry so apps that do not use them do not load them. They are not in the root or `/client`.
|
|
1182
|
+
|
|
1183
|
+
```tsx
|
|
1184
|
+
import { AnimatedNotificationIcon, MorphingMenuIcon } from "@vvedantb/ui/icons/animated";
|
|
1185
|
+
```
|
|
1186
|
+
|
|
1187
|
+
They need the optional `motion` peer and animate through `m.*`, so they need the app's `MotionProvider` (or its own `LazyMotion`). Without it they render their resting frame. No stylesheet. All are decorative (`aria-hidden`) except `AnimatedProgress` (`role="progressbar"`) and `AnimatedCounter` (text).
|
|
1188
|
+
|
|
1189
|
+
| Component | Props | What it does |
|
|
1190
|
+
| --- | --- | --- |
|
|
1191
|
+
| `AnimatedNotificationIcon` | `type` (`NotificationIconType`: `success`, `warning`, `error`, `info`), `size` (20), `color`, `className` | On mount the check draws itself, the warning shakes, the error pops in, the info icon rises. Colour: `--color-success`, `--color-warning`, `--color-danger`, `--color-primary` (`info`); `color` overrides. |
|
|
1192
|
+
| `AnimatedBellIcon` | `size` (32), `muted` (`true`), `className` | Bell draws itself, then swings every 4.5 s. `muted` adds the slash. |
|
|
1193
|
+
| `AnimatedKeyIcon` | `size` (48), `className` | Bolt draws itself, sparkles twinkle, soft pulse. For empty API-key states. |
|
|
1194
|
+
| `MorphingMenuIcon` | `isOpen`, `size` (20), `className` | Three bars morph to an X. `data-state="open"` or `"closed"`. Label the button and set its `aria-expanded`. |
|
|
1195
|
+
| `MorphingMicIcon` | `isListening`, `size` (32), `strokeWidth` (2), `className` | Cross-fades a microphone and a stop square. |
|
|
1196
|
+
| `PulsingRings` | `size`, `ringCount` (3), `color` (`var(--color-danger)`), `active` (`true`), `className` | Staggered expanding rings, absolutely centred in a `relative` parent. `active={false}` renders nothing. |
|
|
1197
|
+
| `AnimatedStatusDot` | `phase` (`StatusDotPhase`: `idle`, `listening`, `thinking`, `speaking`, `error`), `size` (10; svg is 3x), `className` | Ripples while listening, glow while listening or speaking. |
|
|
1198
|
+
| `AnimatedProgress` | `value` (0 to 100, clamped), `height` (4), `showGradient` (`true`), `label`, `className` | Spring fill in `--color-primary` on `--color-surface-secondary`, with a moving gradient and shimmer. |
|
|
1199
|
+
| `AnimatedCounter` | `value`, `duration` (1 s), `formatValue`, `animateOnView` (`true`), `className` | Springs from 0 to `value` once in view. `tabular-nums`. The spring runs without `LazyMotion`. |
|
|
1200
|
+
|
|
1158
1201
|
## Artifact viewers (0.13.0)
|
|
1159
1202
|
|
|
1160
1203
|
Web viewers for artifacts: standalone HTML, Markdown, SVG, code or typed JSON (quiz, flashcards) documents with per-user state. The pure core (schemas, state limits and ops, the document wrapper, the guest bridge) lives in `@vvedantb/server` (see its `docs/artifacts.md`); install `@vvedantb/server` 0.6.1 or later next to this package. The components hold no data: rows come in through props and changes leave through callbacks, so any backend works.
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vvedantb/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.36.0",
|
|
4
4
|
"license": "MIT",
|
|
5
|
-
"description": "Shared React 19 UI for web apps: one main JavaScript entry (primitives, shell pieces, app helpers including the Convex and Clerk provider, VITE env parsing and ensure-user, artifact viewers over the @vvedantb/server artifacts core, native tokens), a client entry without @vvedantb/server, convex or Clerk for Chrome extensions, design-token CSS, shared Tailwind v4 CSS, a Tailwind preset and packaged Instrument Sans fonts.",
|
|
5
|
+
"description": "Shared React 19 UI for web apps: one main JavaScript entry (primitives, shell pieces, app helpers including the Convex and Clerk provider, VITE env parsing and ensure-user, artifact viewers over the @vvedantb/server artifacts core, native tokens), third-party brand logos (also @vvedantb/ui/logos), motion-driven animated SVG icons (@vvedantb/ui/icons/animated), a client entry without @vvedantb/server, convex or Clerk for Chrome extensions, design-token CSS, shared Tailwind v4 CSS, a Tailwind preset and packaged Instrument Sans fonts.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"publishConfig": {
|
|
8
8
|
"access": "public"
|
|
@@ -46,6 +46,14 @@
|
|
|
46
46
|
"types": "./src/brand/index.ts",
|
|
47
47
|
"default": "./src/brand/index.ts"
|
|
48
48
|
},
|
|
49
|
+
"./logos": {
|
|
50
|
+
"types": "./src/logos/index.ts",
|
|
51
|
+
"default": "./src/logos/index.ts"
|
|
52
|
+
},
|
|
53
|
+
"./icons/animated": {
|
|
54
|
+
"types": "./src/icons/animated/index.ts",
|
|
55
|
+
"default": "./src/icons/animated/index.ts"
|
|
56
|
+
},
|
|
49
57
|
"./client": {
|
|
50
58
|
"types": "./src/client.ts",
|
|
51
59
|
"default": "./src/client.ts"
|
package/src/client.ts
CHANGED
|
@@ -399,6 +399,9 @@ export { useHeldResult } from "../app/src/convex/useHeldResult";
|
|
|
399
399
|
// Family app marks (Icon* components, deprecated AppMark and raw SVG strings for favicons).
|
|
400
400
|
export * from "./brand";
|
|
401
401
|
|
|
402
|
+
// Third-party brand logos (`GitHubIcon`, `SlackIcon`, `ClaudeIcon`, ...). Also `@vvedantb/ui/logos`.
|
|
403
|
+
export * from "./logos";
|
|
404
|
+
|
|
402
405
|
// Native tokens (JS values that match tokens.css).
|
|
403
406
|
export { tokens } from "../tokens/src/native";
|
|
404
407
|
export type { Tokens } from "../tokens/src/native";
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { m } from "motion/react";
|
|
2
|
+
|
|
3
|
+
export interface AnimatedBellIconProps {
|
|
4
|
+
/** Width and height in px. Default `32`. */
|
|
5
|
+
size?: number;
|
|
6
|
+
className?: string;
|
|
7
|
+
/** Draw the mute slash. Default `true` (the empty-notifications state). */
|
|
8
|
+
muted?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Bell that draws itself, then swings every few seconds with a moving
|
|
13
|
+
* clapper. For empty notification states. Uses `currentColor`. Decorative (`aria-hidden`).
|
|
14
|
+
*/
|
|
15
|
+
export function AnimatedBellIcon({ size = 32, className, muted = true }: AnimatedBellIconProps) {
|
|
16
|
+
return (
|
|
17
|
+
<m.svg
|
|
18
|
+
width={size}
|
|
19
|
+
height={size}
|
|
20
|
+
viewBox="0 0 24 24"
|
|
21
|
+
fill="none"
|
|
22
|
+
stroke="currentColor"
|
|
23
|
+
strokeWidth="1.5"
|
|
24
|
+
strokeLinecap="round"
|
|
25
|
+
strokeLinejoin="round"
|
|
26
|
+
className={className}
|
|
27
|
+
aria-hidden="true"
|
|
28
|
+
initial={{ rotate: 0 }}
|
|
29
|
+
animate={{ rotate: [0, 10, -10, 8, -8, 5, -5, 0] }}
|
|
30
|
+
transition={{ duration: 1.5, ease: "easeInOut", repeat: Infinity, repeatDelay: 3 }}
|
|
31
|
+
style={{ transformOrigin: "top center" }}
|
|
32
|
+
>
|
|
33
|
+
{/* Body */}
|
|
34
|
+
<m.path
|
|
35
|
+
d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"
|
|
36
|
+
initial={{ pathLength: 0 }}
|
|
37
|
+
animate={{ pathLength: 1 }}
|
|
38
|
+
transition={{ duration: 0.6, ease: "easeOut" }}
|
|
39
|
+
/>
|
|
40
|
+
{/* Bottom curve */}
|
|
41
|
+
<m.path
|
|
42
|
+
d="M13.73 21a2 2 0 0 1-3.46 0"
|
|
43
|
+
initial={{ pathLength: 0 }}
|
|
44
|
+
animate={{ pathLength: 1 }}
|
|
45
|
+
transition={{ duration: 0.3, delay: 0.4 }}
|
|
46
|
+
/>
|
|
47
|
+
{/* Mute slash */}
|
|
48
|
+
{muted ? (
|
|
49
|
+
<m.path
|
|
50
|
+
d="M2 2l20 20"
|
|
51
|
+
data-slot="mute-slash"
|
|
52
|
+
initial={{ pathLength: 0 }}
|
|
53
|
+
animate={{ pathLength: 1 }}
|
|
54
|
+
transition={{ duration: 0.3, delay: 0.6 }}
|
|
55
|
+
/>
|
|
56
|
+
) : null}
|
|
57
|
+
{/* Clapper */}
|
|
58
|
+
<m.circle
|
|
59
|
+
cx="12"
|
|
60
|
+
cy="17"
|
|
61
|
+
r="0.5"
|
|
62
|
+
fill="currentColor"
|
|
63
|
+
animate={{ x: [0, 1, -1, 0.5, -0.5, 0] }}
|
|
64
|
+
transition={{ duration: 1.5, ease: "easeInOut", repeat: Infinity, repeatDelay: 3 }}
|
|
65
|
+
/>
|
|
66
|
+
</m.svg>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { m, useInView, useSpring, useTransform } from "motion/react";
|
|
2
|
+
import { useEffect, useRef } from "react";
|
|
3
|
+
import { cn } from "../../utils/cn";
|
|
4
|
+
|
|
5
|
+
export interface AnimatedCounterProps {
|
|
6
|
+
/** Target value to count up to. */
|
|
7
|
+
value: number;
|
|
8
|
+
/** Rough animation length in seconds (scales the spring stiffness). Default `1`. */
|
|
9
|
+
duration?: number;
|
|
10
|
+
className?: string;
|
|
11
|
+
/** Formats the displayed number. Default: rounded, `toLocaleString()`. */
|
|
12
|
+
formatValue?: (value: number) => string;
|
|
13
|
+
/** Wait until the counter scrolls into view. Default `true`. */
|
|
14
|
+
animateOnView?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const defaultFormat = (v: number) => Math.round(v).toLocaleString();
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Number that springs up from 0 to `value` (`tabular-nums`). Changes to
|
|
21
|
+
* `value` spring from the current number. The spring runs without the app's
|
|
22
|
+
* `LazyMotion`.
|
|
23
|
+
*/
|
|
24
|
+
export function AnimatedCounter({
|
|
25
|
+
value,
|
|
26
|
+
duration = 1,
|
|
27
|
+
className,
|
|
28
|
+
formatValue = defaultFormat,
|
|
29
|
+
animateOnView = true,
|
|
30
|
+
}: AnimatedCounterProps) {
|
|
31
|
+
const ref = useRef<HTMLSpanElement>(null);
|
|
32
|
+
const isInView = useInView(ref, { once: true, margin: "-50px" });
|
|
33
|
+
const springValue = useSpring(0, { stiffness: 100 / duration, damping: 30, restDelta: 0.01 });
|
|
34
|
+
const displayValue = useTransform(springValue, (v) => formatValue(v));
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (!animateOnView || isInView) springValue.set(value);
|
|
38
|
+
}, [value, springValue, animateOnView, isInView]);
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<m.span ref={ref} className={cn("tabular-nums", className)}>
|
|
42
|
+
{displayValue}
|
|
43
|
+
</m.span>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { m } from "motion/react";
|
|
2
|
+
|
|
3
|
+
export interface AnimatedKeyIconProps {
|
|
4
|
+
/** Width and height in px. Default `48`. */
|
|
5
|
+
size?: number;
|
|
6
|
+
className?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function Sparkle({ x, y, delay, size = 3 }: { x: number; y: number; delay: number; size?: number }) {
|
|
10
|
+
return (
|
|
11
|
+
<g transform={`translate(${x}, ${y})`}>
|
|
12
|
+
{/* Four-pointed star */}
|
|
13
|
+
<m.path
|
|
14
|
+
d={`M0 -${size} L0 ${size} M-${size} 0 L${size} 0`}
|
|
15
|
+
stroke="currentColor"
|
|
16
|
+
strokeWidth="1"
|
|
17
|
+
strokeLinecap="round"
|
|
18
|
+
initial={{ scale: 0, opacity: 0, rotate: 0 }}
|
|
19
|
+
animate={{ scale: [0, 1, 0], opacity: [0, 1, 0], rotate: [0, 45, 90] }}
|
|
20
|
+
transition={{ duration: 1.2, delay, repeat: Infinity, repeatDelay: 2, ease: "easeInOut" }}
|
|
21
|
+
/>
|
|
22
|
+
</g>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Lightning-bolt "key" that draws itself, with twinkling sparkles and a soft
|
|
28
|
+
* pulse. For empty API-key states. Uses `currentColor`. Decorative (`aria-hidden`).
|
|
29
|
+
*/
|
|
30
|
+
export function AnimatedKeyIcon({ size = 48, className }: AnimatedKeyIconProps) {
|
|
31
|
+
return (
|
|
32
|
+
<svg
|
|
33
|
+
width={size}
|
|
34
|
+
height={size}
|
|
35
|
+
viewBox="0 0 48 48"
|
|
36
|
+
fill="none"
|
|
37
|
+
stroke="currentColor"
|
|
38
|
+
strokeWidth="1.5"
|
|
39
|
+
strokeLinecap="round"
|
|
40
|
+
strokeLinejoin="round"
|
|
41
|
+
className={className}
|
|
42
|
+
aria-hidden="true"
|
|
43
|
+
>
|
|
44
|
+
<Sparkle x={8} y={10} delay={0} size={2.5} />
|
|
45
|
+
<Sparkle x={40} y={12} delay={0.4} size={2} />
|
|
46
|
+
<Sparkle x={36} y={38} delay={0.8} size={2.5} />
|
|
47
|
+
<Sparkle x={10} y={36} delay={1.2} size={2} />
|
|
48
|
+
|
|
49
|
+
{/* Bolt with a gentle pulse */}
|
|
50
|
+
<m.g
|
|
51
|
+
initial={{ scale: 1 }}
|
|
52
|
+
animate={{ scale: [1, 1.02, 1] }}
|
|
53
|
+
transition={{ duration: 2, repeat: Infinity, ease: "easeInOut" }}
|
|
54
|
+
>
|
|
55
|
+
<m.path
|
|
56
|
+
d="M26 6L14 26h8l-4 16 16-22h-9l5-14z"
|
|
57
|
+
strokeWidth="1.5"
|
|
58
|
+
fill="none"
|
|
59
|
+
initial={{ pathLength: 0, opacity: 0 }}
|
|
60
|
+
animate={{ pathLength: 1, opacity: 1 }}
|
|
61
|
+
transition={{ duration: 0.8, ease: "easeOut" }}
|
|
62
|
+
/>
|
|
63
|
+
</m.g>
|
|
64
|
+
|
|
65
|
+
{/* Glow pulse behind */}
|
|
66
|
+
<m.circle
|
|
67
|
+
cx="24"
|
|
68
|
+
cy="24"
|
|
69
|
+
r="16"
|
|
70
|
+
fill="none"
|
|
71
|
+
stroke="currentColor"
|
|
72
|
+
strokeWidth="0.5"
|
|
73
|
+
initial={{ scale: 0.9, opacity: 0 }}
|
|
74
|
+
animate={{ scale: [0.9, 1.1, 0.9], opacity: [0, 0.15, 0] }}
|
|
75
|
+
transition={{ duration: 3, repeat: Infinity, ease: "easeInOut" }}
|
|
76
|
+
/>
|
|
77
|
+
</svg>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { m } from "motion/react";
|
|
2
|
+
|
|
3
|
+
export type NotificationIconType = "success" | "warning" | "error" | "info";
|
|
4
|
+
|
|
5
|
+
export interface AnimatedNotificationIconProps {
|
|
6
|
+
type: NotificationIconType;
|
|
7
|
+
/** Width and height in px. Default `20`. */
|
|
8
|
+
size?: number;
|
|
9
|
+
/** Stroke colour. Default: the type's token (`--color-success`, `--color-warning`, `--color-danger`, `--color-primary` for `info`). */
|
|
10
|
+
color?: string;
|
|
11
|
+
/** Class on the wrapping `span`. */
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const TYPE_COLORS: Record<NotificationIconType, string> = {
|
|
16
|
+
success: "var(--color-success)",
|
|
17
|
+
warning: "var(--color-warning)",
|
|
18
|
+
error: "var(--color-danger)",
|
|
19
|
+
info: "var(--color-primary)",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
interface PartProps {
|
|
23
|
+
size: number;
|
|
24
|
+
color: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function SuccessIcon({ size, color }: PartProps) {
|
|
28
|
+
return (
|
|
29
|
+
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
30
|
+
{/* Circle */}
|
|
31
|
+
<m.circle
|
|
32
|
+
cx="12"
|
|
33
|
+
cy="12"
|
|
34
|
+
r="10"
|
|
35
|
+
stroke={color}
|
|
36
|
+
strokeWidth="1.5"
|
|
37
|
+
fill="none"
|
|
38
|
+
initial={{ pathLength: 0, opacity: 0 }}
|
|
39
|
+
animate={{ pathLength: 1, opacity: 1 }}
|
|
40
|
+
transition={{ duration: 0.4, ease: "easeOut" }}
|
|
41
|
+
/>
|
|
42
|
+
{/* Checkmark draws itself */}
|
|
43
|
+
<m.path
|
|
44
|
+
d="M8 12l2.5 2.5L16 9"
|
|
45
|
+
stroke={color}
|
|
46
|
+
strokeWidth="1.5"
|
|
47
|
+
strokeLinecap="round"
|
|
48
|
+
strokeLinejoin="round"
|
|
49
|
+
fill="none"
|
|
50
|
+
initial={{ pathLength: 0 }}
|
|
51
|
+
animate={{ pathLength: 1 }}
|
|
52
|
+
transition={{ duration: 0.3, delay: 0.3, ease: "easeOut" }}
|
|
53
|
+
/>
|
|
54
|
+
</svg>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function WarningIcon({ size, color }: PartProps) {
|
|
59
|
+
return (
|
|
60
|
+
<m.svg
|
|
61
|
+
width={size}
|
|
62
|
+
height={size}
|
|
63
|
+
viewBox="0 0 24 24"
|
|
64
|
+
fill="none"
|
|
65
|
+
aria-hidden="true"
|
|
66
|
+
initial={{ rotate: 0 }}
|
|
67
|
+
animate={{ rotate: [0, -5, 5, -3, 3, 0] }}
|
|
68
|
+
transition={{ duration: 0.5, delay: 0.2 }}
|
|
69
|
+
>
|
|
70
|
+
{/* Triangle */}
|
|
71
|
+
<m.path
|
|
72
|
+
d="M12 2L2 20h20L12 2z"
|
|
73
|
+
stroke={color}
|
|
74
|
+
strokeWidth="1.5"
|
|
75
|
+
strokeLinecap="round"
|
|
76
|
+
strokeLinejoin="round"
|
|
77
|
+
fill="none"
|
|
78
|
+
initial={{ pathLength: 0, opacity: 0 }}
|
|
79
|
+
animate={{ pathLength: 1, opacity: 1 }}
|
|
80
|
+
transition={{ duration: 0.4, ease: "easeOut" }}
|
|
81
|
+
/>
|
|
82
|
+
{/* Exclamation line */}
|
|
83
|
+
<m.path
|
|
84
|
+
d="M12 9v4"
|
|
85
|
+
stroke={color}
|
|
86
|
+
strokeWidth="1.5"
|
|
87
|
+
strokeLinecap="round"
|
|
88
|
+
initial={{ pathLength: 0 }}
|
|
89
|
+
animate={{ pathLength: 1 }}
|
|
90
|
+
transition={{ duration: 0.2, delay: 0.3 }}
|
|
91
|
+
/>
|
|
92
|
+
{/* Exclamation dot */}
|
|
93
|
+
<m.circle
|
|
94
|
+
cx="12"
|
|
95
|
+
cy="17"
|
|
96
|
+
r="1"
|
|
97
|
+
fill={color}
|
|
98
|
+
initial={{ scale: 0 }}
|
|
99
|
+
animate={{ scale: 1 }}
|
|
100
|
+
transition={{ duration: 0.15, delay: 0.45, type: "spring" }}
|
|
101
|
+
/>
|
|
102
|
+
</m.svg>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function ErrorIcon({ size, color }: PartProps) {
|
|
107
|
+
return (
|
|
108
|
+
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
109
|
+
{/* Circle */}
|
|
110
|
+
<m.circle
|
|
111
|
+
cx="12"
|
|
112
|
+
cy="12"
|
|
113
|
+
r="10"
|
|
114
|
+
stroke={color}
|
|
115
|
+
strokeWidth="1.5"
|
|
116
|
+
fill="none"
|
|
117
|
+
initial={{ scale: 0.8, opacity: 0 }}
|
|
118
|
+
animate={{ scale: 1, opacity: 1 }}
|
|
119
|
+
transition={{ duration: 0.3, ease: "easeOut" }}
|
|
120
|
+
/>
|
|
121
|
+
{/* X, first stroke */}
|
|
122
|
+
<m.path
|
|
123
|
+
d="M15 9l-6 6"
|
|
124
|
+
stroke={color}
|
|
125
|
+
strokeWidth="1.5"
|
|
126
|
+
strokeLinecap="round"
|
|
127
|
+
initial={{ pathLength: 0 }}
|
|
128
|
+
animate={{ pathLength: 1 }}
|
|
129
|
+
transition={{ duration: 0.2, delay: 0.2 }}
|
|
130
|
+
/>
|
|
131
|
+
{/* X, second stroke */}
|
|
132
|
+
<m.path
|
|
133
|
+
d="M9 9l6 6"
|
|
134
|
+
stroke={color}
|
|
135
|
+
strokeWidth="1.5"
|
|
136
|
+
strokeLinecap="round"
|
|
137
|
+
initial={{ pathLength: 0 }}
|
|
138
|
+
animate={{ pathLength: 1 }}
|
|
139
|
+
transition={{ duration: 0.2, delay: 0.3 }}
|
|
140
|
+
/>
|
|
141
|
+
</svg>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function InfoIcon({ size, color }: PartProps) {
|
|
146
|
+
return (
|
|
147
|
+
<m.svg
|
|
148
|
+
width={size}
|
|
149
|
+
height={size}
|
|
150
|
+
viewBox="0 0 24 24"
|
|
151
|
+
fill="none"
|
|
152
|
+
aria-hidden="true"
|
|
153
|
+
initial={{ y: 2, opacity: 0 }}
|
|
154
|
+
animate={{ y: 0, opacity: 1 }}
|
|
155
|
+
transition={{ duration: 0.3, ease: "easeOut" }}
|
|
156
|
+
>
|
|
157
|
+
{/* Circle */}
|
|
158
|
+
<circle cx="12" cy="12" r="10" stroke={color} strokeWidth="1.5" fill="none" />
|
|
159
|
+
{/* Dot */}
|
|
160
|
+
<m.circle
|
|
161
|
+
cx="12"
|
|
162
|
+
cy="8"
|
|
163
|
+
r="1"
|
|
164
|
+
fill={color}
|
|
165
|
+
initial={{ scale: 0 }}
|
|
166
|
+
animate={{ scale: 1 }}
|
|
167
|
+
transition={{ duration: 0.2, delay: 0.2, type: "spring" }}
|
|
168
|
+
/>
|
|
169
|
+
{/* Line */}
|
|
170
|
+
<m.path
|
|
171
|
+
d="M12 12v4"
|
|
172
|
+
stroke={color}
|
|
173
|
+
strokeWidth="1.5"
|
|
174
|
+
strokeLinecap="round"
|
|
175
|
+
initial={{ pathLength: 0 }}
|
|
176
|
+
animate={{ pathLength: 1 }}
|
|
177
|
+
transition={{ duration: 0.2, delay: 0.3 }}
|
|
178
|
+
/>
|
|
179
|
+
</m.svg>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const PARTS: Record<NotificationIconType, (props: PartProps) => React.JSX.Element> = {
|
|
184
|
+
success: SuccessIcon,
|
|
185
|
+
warning: WarningIcon,
|
|
186
|
+
error: ErrorIcon,
|
|
187
|
+
info: InfoIcon,
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Notification status icon that animates on mount: the check draws itself,
|
|
192
|
+
* the warning shakes, the error pops in and the info icon rises. Decorative
|
|
193
|
+
* (`aria-hidden`); put the message text beside it.
|
|
194
|
+
*/
|
|
195
|
+
export function AnimatedNotificationIcon({ type, size = 20, color, className }: AnimatedNotificationIconProps) {
|
|
196
|
+
const Part = PARTS[type];
|
|
197
|
+
return (
|
|
198
|
+
<span className={className} data-type={type}>
|
|
199
|
+
<Part size={size} color={color ?? TYPE_COLORS[type]} />
|
|
200
|
+
</span>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { m, useSpring, useTransform } from "motion/react";
|
|
2
|
+
import { useEffect, useId } from "react";
|
|
3
|
+
|
|
4
|
+
export interface AnimatedProgressProps {
|
|
5
|
+
/** Progress from 0 to 100 (clamped). */
|
|
6
|
+
value: number;
|
|
7
|
+
/** Bar height in px. Default `4`. */
|
|
8
|
+
height?: number;
|
|
9
|
+
/** Class on the wrapping `div`. */
|
|
10
|
+
className?: string;
|
|
11
|
+
/** Run the moving gradient and shimmer. Default `true`. */
|
|
12
|
+
showGradient?: boolean;
|
|
13
|
+
/** Accessible name for the `progressbar`. */
|
|
14
|
+
label?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Thin progress bar whose fill springs to `value`, with a moving gradient
|
|
19
|
+
* and shimmer (SVG `<animate>`). Fill: `--color-primary`; track:
|
|
20
|
+
* `--color-surface-secondary`. Renders `role="progressbar"`.
|
|
21
|
+
*/
|
|
22
|
+
export function AnimatedProgress({ value, height = 4, className, showGradient = true, label }: AnimatedProgressProps) {
|
|
23
|
+
const id = useId();
|
|
24
|
+
const gradientId = `${id}-gradient`;
|
|
25
|
+
const shimmerId = `${id}-shimmer`;
|
|
26
|
+
const clamped = Math.min(Math.max(value, 0), 100);
|
|
27
|
+
const springValue = useSpring(0, { stiffness: 100, damping: 30 });
|
|
28
|
+
const width = useTransform(springValue, (v) => `${v}%`);
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
springValue.set(clamped);
|
|
32
|
+
}, [clamped, springValue]);
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div
|
|
36
|
+
className={className}
|
|
37
|
+
role="progressbar"
|
|
38
|
+
aria-label={label}
|
|
39
|
+
aria-valuemin={0}
|
|
40
|
+
aria-valuemax={100}
|
|
41
|
+
aria-valuenow={Math.round(clamped)}
|
|
42
|
+
style={{ height, overflow: "hidden", borderRadius: height / 2 }}
|
|
43
|
+
>
|
|
44
|
+
<svg width="100%" height={height} className="block" aria-hidden="true">
|
|
45
|
+
<defs>
|
|
46
|
+
{/* Moving gradient */}
|
|
47
|
+
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
|
|
48
|
+
<stop offset="0%" stopColor="var(--color-primary)" />
|
|
49
|
+
<stop offset="50%" stopColor="color-mix(in oklch, var(--color-primary) 80%, transparent)" />
|
|
50
|
+
<stop offset="100%" stopColor="var(--color-primary)" />
|
|
51
|
+
{showGradient ? <animate attributeName="x1" values="-100%;100%" dur="2s" repeatCount="indefinite" /> : null}
|
|
52
|
+
{showGradient ? <animate attributeName="x2" values="0%;200%" dur="2s" repeatCount="indefinite" /> : null}
|
|
53
|
+
</linearGradient>
|
|
54
|
+
{/* Shimmer overlay */}
|
|
55
|
+
<linearGradient id={shimmerId} x1="0%" y1="0%" x2="100%" y2="0%">
|
|
56
|
+
<stop offset="0%" stopColor="transparent" />
|
|
57
|
+
<stop offset="50%" stopColor="rgba(255,255,255,0.3)" />
|
|
58
|
+
<stop offset="100%" stopColor="transparent" />
|
|
59
|
+
<animate attributeName="x1" values="-50%;150%" dur="1.5s" repeatCount="indefinite" />
|
|
60
|
+
<animate attributeName="x2" values="0%;200%" dur="1.5s" repeatCount="indefinite" />
|
|
61
|
+
</linearGradient>
|
|
62
|
+
</defs>
|
|
63
|
+
{/* Track */}
|
|
64
|
+
<rect x="0" y="0" width="100%" height={height} rx={height / 2} fill="var(--color-surface-secondary)" />
|
|
65
|
+
{/* Fill */}
|
|
66
|
+
<m.rect x="0" y="0" height={height} rx={height / 2} fill={`url(#${gradientId})`} style={{ width }} />
|
|
67
|
+
{/* Shimmer */}
|
|
68
|
+
{showGradient && clamped > 0 ? (
|
|
69
|
+
<m.rect x="0" y="0" height={height} rx={height / 2} fill={`url(#${shimmerId})`} style={{ width }} />
|
|
70
|
+
) : null}
|
|
71
|
+
</svg>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { m } from "motion/react";
|
|
2
|
+
import { useId } from "react";
|
|
3
|
+
|
|
4
|
+
export type StatusDotPhase = "idle" | "listening" | "thinking" | "speaking" | "error";
|
|
5
|
+
|
|
6
|
+
export interface AnimatedStatusDotProps {
|
|
7
|
+
phase: StatusDotPhase;
|
|
8
|
+
/** Dot diameter in px. The svg is `size * 3` square to leave room for the ripples. Default `10`. */
|
|
9
|
+
size?: number;
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const PHASE_COLORS: Record<StatusDotPhase, string> = {
|
|
14
|
+
idle: "var(--color-muted-foreground)",
|
|
15
|
+
listening: "var(--color-danger)",
|
|
16
|
+
thinking: "var(--color-warning)",
|
|
17
|
+
speaking: "var(--color-success)",
|
|
18
|
+
error: "var(--color-danger)",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const RIPPLE_DELAYS = [0, 0.5, 1];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Status dot for voice states: ripples while `listening`, a glow pulse while
|
|
25
|
+
* `listening` or `speaking`. Decorative (`aria-hidden`); show the phase as text too.
|
|
26
|
+
*/
|
|
27
|
+
export function AnimatedStatusDot({ phase, size = 10, className }: AnimatedStatusDotProps) {
|
|
28
|
+
const filterId = `${useId()}-glow`;
|
|
29
|
+
const color = PHASE_COLORS[phase];
|
|
30
|
+
const isListening = phase === "listening";
|
|
31
|
+
const isActive = isListening || phase === "speaking";
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<svg
|
|
35
|
+
width={size * 3}
|
|
36
|
+
height={size * 3}
|
|
37
|
+
viewBox="0 0 30 30"
|
|
38
|
+
className={className}
|
|
39
|
+
overflow="visible"
|
|
40
|
+
aria-hidden="true"
|
|
41
|
+
data-phase={phase}
|
|
42
|
+
>
|
|
43
|
+
<defs>
|
|
44
|
+
<filter id={filterId} x="-50%" y="-50%" width="200%" height="200%">
|
|
45
|
+
<feGaussianBlur stdDeviation="2" result="coloredBlur" />
|
|
46
|
+
<feMerge>
|
|
47
|
+
<feMergeNode in="coloredBlur" />
|
|
48
|
+
<feMergeNode in="SourceGraphic" />
|
|
49
|
+
</feMerge>
|
|
50
|
+
</filter>
|
|
51
|
+
</defs>
|
|
52
|
+
|
|
53
|
+
{/* Ripples while listening */}
|
|
54
|
+
{isListening
|
|
55
|
+
? RIPPLE_DELAYS.map((delay) => (
|
|
56
|
+
<m.circle
|
|
57
|
+
key={delay}
|
|
58
|
+
cx="15"
|
|
59
|
+
cy="15"
|
|
60
|
+
r="5"
|
|
61
|
+
fill="none"
|
|
62
|
+
stroke={color}
|
|
63
|
+
strokeWidth="1"
|
|
64
|
+
initial={{ r: 5, opacity: 0.6 }}
|
|
65
|
+
animate={{ r: 14, opacity: 0 }}
|
|
66
|
+
transition={{ duration: 1.5, repeat: Infinity, ease: "easeOut", delay }}
|
|
67
|
+
/>
|
|
68
|
+
))
|
|
69
|
+
: null}
|
|
70
|
+
|
|
71
|
+
{/* Glow ring while active */}
|
|
72
|
+
{isActive ? (
|
|
73
|
+
<m.circle
|
|
74
|
+
cx="15"
|
|
75
|
+
cy="15"
|
|
76
|
+
r="7"
|
|
77
|
+
fill={color}
|
|
78
|
+
opacity={0.3}
|
|
79
|
+
initial={{ scale: 1 }}
|
|
80
|
+
animate={{ scale: [1, 1.2, 1], opacity: [0.3, 0.15, 0.3] }}
|
|
81
|
+
transition={{ duration: 2, repeat: Infinity, ease: "easeInOut" }}
|
|
82
|
+
/>
|
|
83
|
+
) : null}
|
|
84
|
+
|
|
85
|
+
{/* Dot */}
|
|
86
|
+
<m.circle
|
|
87
|
+
cx="15"
|
|
88
|
+
cy="15"
|
|
89
|
+
r="5"
|
|
90
|
+
fill={color}
|
|
91
|
+
filter={isActive ? `url(#${filterId})` : undefined}
|
|
92
|
+
initial={{ scale: 0.8, opacity: 0 }}
|
|
93
|
+
animate={{ scale: 1, opacity: 1 }}
|
|
94
|
+
transition={{ duration: 0.2 }}
|
|
95
|
+
/>
|
|
96
|
+
</svg>
|
|
97
|
+
);
|
|
98
|
+
}
|