@vvedantb/ui 0.35.0 → 0.37.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 +70 -8
- package/package.json +23 -16
- package/src/brand/index.ts +1 -0
- package/src/brand/productApps.ts +20 -0
- package/src/client.ts +15 -2
- 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 +15 -2
- 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/src/marketing/SiteFooter.tsx +86 -22
- package/src/marketing/landing.tsx +35 -7
- package/src/ui/page-title.tsx +29 -0
- package/app/README.md +0 -269
- package/tokens/README.md +0 -122
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`. |
|
|
@@ -888,7 +890,7 @@ Plain parts, no Motion:
|
|
|
888
890
|
| `LandingLattice` | `className` (grid columns). Cells share 1px borders. |
|
|
889
891
|
| `LandingFeatureCard` | `icon` (a component, rendered `size={18} stroke={1.5}`), `title`, `description`. |
|
|
890
892
|
| `LandingNavMenu` | `links`, `github` (`href`, `icon`). Overflow menu below `md`. |
|
|
891
|
-
| `LandingFooter` | `brand`, `tagline`, `links`, `actions` (after the links), `github` (`href`, `label`, `icon`). |
|
|
893
|
+
| `LandingFooter` | New path (0.37.0): `app`, `tagline`, `links` (a "Navigation" column) or `columns`, `actions`, plus the `SiteFooter` options. A thin wrapper over `SiteFooter` with every Project V default. Deprecated path (no `app`): `brand`, `tagline`, `links`, `actions` (after the links), `github` (`href`, `label`, `icon`), in the old compact layout. `brand` and `github` are ignored when `app` is set. |
|
|
892
894
|
|
|
893
895
|
Constants: `landingShellClass`, `landingPanelClass`, `landingItemVariants`, and
|
|
894
896
|
for the footer's `actions` slot `landingFooterLinkClass` (a text link) and
|
|
@@ -896,17 +898,36 @@ for the footer's `actions` slot `landingFooterLinkClass` (a text link) and
|
|
|
896
898
|
|
|
897
899
|
### `SiteFooter`
|
|
898
900
|
|
|
899
|
-
Brand, tagline and social icon buttons; link columns; then copyright and legal
|
|
900
|
-
links under a separator.
|
|
901
|
+
Brand, tagline and social icon buttons; link columns; then copyright, product-of line and legal
|
|
902
|
+
links under a separator. Set `app` for the Project V footer (0.37.0):
|
|
903
|
+
|
|
904
|
+
```tsx
|
|
905
|
+
<SiteFooter
|
|
906
|
+
app="vibot"
|
|
907
|
+
tagline="AI-powered assistant designed to automate tasks, and enhance productivity effortlessly."
|
|
908
|
+
columns={[{ heading: "Navigation", links: [{ href: "#features", label: "Features" }] }]}
|
|
909
|
+
/>
|
|
910
|
+
```
|
|
901
911
|
|
|
902
912
|
| Prop | |
|
|
903
913
|
| --- | --- |
|
|
904
|
-
| `
|
|
905
|
-
| `
|
|
906
|
-
| `columns` | `{ heading, links }[]`. A link is `{ href, label, external?, image?: { src, alt }, app?: AppName }`. `image` is a 20px round image before the label. `app`
|
|
907
|
-
| `
|
|
908
|
-
| `
|
|
914
|
+
| `app` | `vibot`, `vmem`, `verve`, `velth` or `vhub`. Brand is `AppWordmark` at `SITE_FOOTER_WORDMARK_SIZE` (16px; no size prop). Copyright name and product-of line use the lowercase id. |
|
|
915
|
+
| `tagline` | Node. |
|
|
916
|
+
| `columns` | Required. `{ heading, links }[]`. A link is `{ href, label, external?, image?: { src, alt }, app?: AppName }`. `image` is a 20px round image before the label. `app` is the 14px `APP_ICONS[app]` glyph before the label, in `currentColor` and `aria-hidden`; it wins over `image`. |
|
|
917
|
+
| `includeAppsColumn` | Default `true`: appends `PROJECT_V_PRODUCT_APPS` (vibot, vmem, verve, velth, vhub) unless `columns` has an "Apps" heading (case-insensitive). `projectVAppsColumn()` returns the column for composition. |
|
|
918
|
+
| `showProductOf` | Default `true`: "{app} is a product of Project V." linking to `https://projectv.uk`. |
|
|
919
|
+
| `socials` | Default `PROJECT_V_SOCIALS` (X, Instagram, GitHub). `[]` hides them. `{ href, icon, label? }[]`; links open in a new tab. |
|
|
920
|
+
| `legalLinks` | Default `PROJECT_V_LEGAL_LINKS` (Privacy Policy, Terms of Service). |
|
|
921
|
+
| `actions` | Node under the socials, for example Sign in. |
|
|
922
|
+
| `year` | Default: the current year. |
|
|
909
923
|
| `variant` | `"muted"` (default, `text-muted`), `"muted-foreground"`, or `"touch"` (`text-muted-foreground`, `max-sm:hit-target` links, safe-area bottom padding). |
|
|
924
|
+
| `brand`, `name` | Optional overrides on the `app` path. |
|
|
925
|
+
|
|
926
|
+
**Deprecated (no `app`):** `brand`, `socials`, `name` and `legalLinks` are required, and the footer renders as before: the brand sits in the Michroma wrapper, with no product-of line and no Apps column. `actions`, `includeAppsColumn` and `showProductOf` do nothing. Set `app` instead.
|
|
927
|
+
|
|
928
|
+
### `PageTitle` and `PageHeader`
|
|
929
|
+
|
|
930
|
+
`PageTitle` is an `h1` with `text-xl font-semibold leading-tight tracking-tight` (`pageTitleClass`): Instrument Sans, 20px, weight 600, never lighter. `PageHeader` is a title row: `title` in a `PageTitle`, an optional `action` slot, and no safe-area padding.
|
|
910
931
|
|
|
911
932
|
### `Icon*` app icons
|
|
912
933
|
|
|
@@ -1155,6 +1176,47 @@ Props (type `IconProps`; all optional):
|
|
|
1155
1176
|
|
|
1156
1177
|
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
1178
|
|
|
1179
|
+
## Third-party logos (0.36.0)
|
|
1180
|
+
|
|
1181
|
+
Full-colour vendor logos, separate from the family marks in `@vvedantb/ui/brand`. Import from `@vvedantb/ui/logos` (also in the root and `/client`):
|
|
1182
|
+
|
|
1183
|
+
```tsx
|
|
1184
|
+
import { GitHubIcon, GoogleDriveIcon, SlackIcon } from "@vvedantb/ui/logos";
|
|
1185
|
+
|
|
1186
|
+
<GoogleDriveIcon /> // 24px, aria-hidden
|
|
1187
|
+
<SlackIcon size={16} title="Slack" /> // role="img", aria-label and <title>
|
|
1188
|
+
<GitHubIcon className="text-foreground" /> // monochrome marks follow currentColor
|
|
1189
|
+
```
|
|
1190
|
+
|
|
1191
|
+
| Logo | Colour |
|
|
1192
|
+
| --- | --- |
|
|
1193
|
+
| `ChromeIcon`, `ConvexIcon`, `DropboxIcon`, `FigmaIcon`, `GmailIcon`, `GoogleDriveIcon`, `LinearIcon`, `NotionIcon`, `OneDriveIcon`, `SharePointIcon`, `SlackIcon` | Brand colours |
|
|
1194
|
+
| `ClaudeIcon`, `CursorIcon`, `GitHubIcon`, `VercelIcon` | `currentColor` |
|
|
1195
|
+
|
|
1196
|
+
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`.
|
|
1197
|
+
|
|
1198
|
+
## Animated SVG icons (0.36.0)
|
|
1199
|
+
|
|
1200
|
+
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`.
|
|
1201
|
+
|
|
1202
|
+
```tsx
|
|
1203
|
+
import { AnimatedNotificationIcon, MorphingMenuIcon } from "@vvedantb/ui/icons/animated";
|
|
1204
|
+
```
|
|
1205
|
+
|
|
1206
|
+
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).
|
|
1207
|
+
|
|
1208
|
+
| Component | Props | What it does |
|
|
1209
|
+
| --- | --- | --- |
|
|
1210
|
+
| `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. |
|
|
1211
|
+
| `AnimatedBellIcon` | `size` (32), `muted` (`true`), `className` | Bell draws itself, then swings every 4.5 s. `muted` adds the slash. |
|
|
1212
|
+
| `AnimatedKeyIcon` | `size` (48), `className` | Bolt draws itself, sparkles twinkle, soft pulse. For empty API-key states. |
|
|
1213
|
+
| `MorphingMenuIcon` | `isOpen`, `size` (20), `className` | Three bars morph to an X. `data-state="open"` or `"closed"`. Label the button and set its `aria-expanded`. |
|
|
1214
|
+
| `MorphingMicIcon` | `isListening`, `size` (32), `strokeWidth` (2), `className` | Cross-fades a microphone and a stop square. |
|
|
1215
|
+
| `PulsingRings` | `size`, `ringCount` (3), `color` (`var(--color-danger)`), `active` (`true`), `className` | Staggered expanding rings, absolutely centred in a `relative` parent. `active={false}` renders nothing. |
|
|
1216
|
+
| `AnimatedStatusDot` | `phase` (`StatusDotPhase`: `idle`, `listening`, `thinking`, `speaking`, `error`), `size` (10; svg is 3x), `className` | Ripples while listening, glow while listening or speaking. |
|
|
1217
|
+
| `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. |
|
|
1218
|
+
| `AnimatedCounter` | `value`, `duration` (1 s), `formatValue`, `animateOnView` (`true`), `className` | Springs from 0 to `value` once in view. `tabular-nums`. The spring runs without `LazyMotion`. |
|
|
1219
|
+
|
|
1158
1220
|
## Artifact viewers (0.13.0)
|
|
1159
1221
|
|
|
1160
1222
|
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.37.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"
|
|
@@ -73,17 +81,6 @@
|
|
|
73
81
|
"./src/marketing/landing.css",
|
|
74
82
|
"./fonts/instrument-sans.css"
|
|
75
83
|
],
|
|
76
|
-
"scripts": {
|
|
77
|
-
"check:tokens": "node tokens/scripts/check-component-tokens.mjs",
|
|
78
|
-
"build": "node tokens/scripts/check-exports.mjs && node tokens/scripts/check-component-tokens.mjs && tsc -p tsconfig.json",
|
|
79
|
-
"typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.consumer.json && tsc -p tsconfig.consumer-speech.json && tsc -p tokens/tsconfig.json",
|
|
80
|
-
"test": "node tokens/scripts/check-component-tokens.mjs && vitest run && node --test tokens/test/*.test.mjs app/tests-node/*.test.mjs",
|
|
81
|
-
"verify": "node tokens/scripts/check-component-tokens.mjs && node tokens/verify/run.mjs",
|
|
82
|
-
"analyze": "node tokens/verify/analyze.mjs",
|
|
83
|
-
"brand:assets": "node scripts/build-brand-assets.mjs",
|
|
84
|
-
"brand:sheet": "node scripts/build-brand-sheet.mjs",
|
|
85
|
-
"prepack": "node tokens/scripts/check-exports.mjs"
|
|
86
|
-
},
|
|
87
84
|
"dependencies": {
|
|
88
85
|
"@dnd-kit/core": "^6.3.1",
|
|
89
86
|
"@dnd-kit/modifiers": "^9.0.0",
|
|
@@ -174,7 +171,6 @@
|
|
|
174
171
|
"@tanstack/react-hotkeys": "^0.13.0",
|
|
175
172
|
"@types/react": "^19.1.0",
|
|
176
173
|
"@types/react-dom": "^19.1.0",
|
|
177
|
-
"@vvedantb/server": "workspace:*",
|
|
178
174
|
"ai": "^5.0.0",
|
|
179
175
|
"class-variance-authority": "^0.7.1",
|
|
180
176
|
"clsx": "^2.1.1",
|
|
@@ -198,7 +194,8 @@
|
|
|
198
194
|
"vaul": "^1.1.0",
|
|
199
195
|
"vitest": "^3.2.0",
|
|
200
196
|
"zod": "^4.0.0",
|
|
201
|
-
"zod3": "npm:zod@^3.25.76"
|
|
197
|
+
"zod3": "npm:zod@^3.25.76",
|
|
198
|
+
"@vvedantb/server": "0.14.0"
|
|
202
199
|
},
|
|
203
200
|
"peerDependenciesMeta": {
|
|
204
201
|
"@clerk/clerk-react": {
|
|
@@ -219,5 +216,15 @@
|
|
|
219
216
|
"@vvedantb/server": {
|
|
220
217
|
"optional": true
|
|
221
218
|
}
|
|
219
|
+
},
|
|
220
|
+
"scripts": {
|
|
221
|
+
"check:tokens": "node tokens/scripts/check-component-tokens.mjs",
|
|
222
|
+
"build": "node tokens/scripts/check-exports.mjs && node tokens/scripts/check-component-tokens.mjs && tsc -p tsconfig.json",
|
|
223
|
+
"typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.consumer.json && tsc -p tsconfig.consumer-speech.json && tsc -p tokens/tsconfig.json",
|
|
224
|
+
"test": "node tokens/scripts/check-component-tokens.mjs && vitest run && node --test tokens/test/*.test.mjs app/tests-node/*.test.mjs",
|
|
225
|
+
"verify": "node tokens/scripts/check-component-tokens.mjs && node tokens/verify/run.mjs",
|
|
226
|
+
"analyze": "node tokens/verify/analyze.mjs",
|
|
227
|
+
"brand:assets": "node scripts/build-brand-assets.mjs",
|
|
228
|
+
"brand:sheet": "node scripts/build-brand-sheet.mjs"
|
|
222
229
|
}
|
|
223
|
-
}
|
|
230
|
+
}
|
package/src/brand/index.ts
CHANGED
|
@@ -28,3 +28,4 @@ export { FAMILY_APPS, familyApp } from "./familyApps";
|
|
|
28
28
|
export type { FamilyApp, FamilyAppName } from "./familyApps";
|
|
29
29
|
export { V_PRO_APP_BENEFITS } from "./vProAppBenefits";
|
|
30
30
|
export type { PricingPlanAppBenefit } from "./vProAppBenefits";
|
|
31
|
+
export { PROJECT_V_PRODUCT_APPS, PROJECT_V_PRODUCT_APP_IDS, projectVAppsColumn } from "./productApps";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ProductAppName } from "./AppWordmark";
|
|
2
|
+
|
|
3
|
+
/** The five product apps in footer order (not `FAMILY_APPS` order). */
|
|
4
|
+
export const PROJECT_V_PRODUCT_APP_IDS: readonly ProductAppName[] = ["vibot", "vmem", "verve", "velth", "vhub"];
|
|
5
|
+
|
|
6
|
+
/** Apps column: each app links to `https://{app}.projectv.uk` in a new tab, with its glyph and lowercase id as the label. */
|
|
7
|
+
export function projectVAppsColumn() {
|
|
8
|
+
return {
|
|
9
|
+
heading: "Apps",
|
|
10
|
+
links: PROJECT_V_PRODUCT_APP_IDS.map((app) => ({
|
|
11
|
+
href: `https://${app}.projectv.uk`,
|
|
12
|
+
label: app,
|
|
13
|
+
external: true as const,
|
|
14
|
+
app,
|
|
15
|
+
})),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** `projectVAppsColumn()`, built once. */
|
|
20
|
+
export const PROJECT_V_PRODUCT_APPS = projectVAppsColumn();
|
package/src/client.ts
CHANGED
|
@@ -54,6 +54,7 @@ export * from "./ui/avatar";
|
|
|
54
54
|
export * from "./ui/badge";
|
|
55
55
|
export * from "./ui/border-beam";
|
|
56
56
|
export * from "./ui/breadcrumb";
|
|
57
|
+
export * from "./ui/page-title";
|
|
57
58
|
export * from "./ui/button";
|
|
58
59
|
export * from "./ui/button-group";
|
|
59
60
|
export * from "./ui/card";
|
|
@@ -285,6 +286,8 @@ export {
|
|
|
285
286
|
} from "./marketing/landing";
|
|
286
287
|
export type {
|
|
287
288
|
LandingFeature,
|
|
289
|
+
LandingFooterAppProps,
|
|
290
|
+
LandingFooterLegacyProps,
|
|
288
291
|
LandingFooterProps,
|
|
289
292
|
LandingLatticeProps,
|
|
290
293
|
LandingLink,
|
|
@@ -311,8 +314,15 @@ export {
|
|
|
311
314
|
landingShellClass,
|
|
312
315
|
} from "./marketing/marketing-classes";
|
|
313
316
|
export type { PricingModalTone, SiteFooterVariant } from "./marketing/marketing-classes";
|
|
314
|
-
export { SiteFooter } from "./marketing/SiteFooter";
|
|
315
|
-
export type {
|
|
317
|
+
export { PROJECT_V_LEGAL_LINKS, PROJECT_V_SOCIALS, SITE_FOOTER_WORDMARK_SIZE, SiteFooter } from "./marketing/SiteFooter";
|
|
318
|
+
export type {
|
|
319
|
+
SiteFooterAppProps,
|
|
320
|
+
SiteFooterColumn,
|
|
321
|
+
SiteFooterLegacyProps,
|
|
322
|
+
SiteFooterLink,
|
|
323
|
+
SiteFooterProps,
|
|
324
|
+
SiteFooterSocial,
|
|
325
|
+
} from "./marketing/SiteFooter";
|
|
316
326
|
export { PricingModal, V_PRO_APP_BENEFITS } from "./marketing/PricingModal";
|
|
317
327
|
export type {
|
|
318
328
|
PricingModalLegacyProps,
|
|
@@ -399,6 +409,9 @@ export { useHeldResult } from "../app/src/convex/useHeldResult";
|
|
|
399
409
|
// Family app marks (Icon* components, deprecated AppMark and raw SVG strings for favicons).
|
|
400
410
|
export * from "./brand";
|
|
401
411
|
|
|
412
|
+
// Third-party brand logos (`GitHubIcon`, `SlackIcon`, `ClaudeIcon`, ...). Also `@vvedantb/ui/logos`.
|
|
413
|
+
export * from "./logos";
|
|
414
|
+
|
|
402
415
|
// Native tokens (JS values that match tokens.css).
|
|
403
416
|
export { tokens } from "../tokens/src/native";
|
|
404
417
|
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
|
+
}
|