@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 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.35.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
+ }