@vvedantb/ui 0.20.0 → 0.23.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 +115 -13
- package/package.json +8 -1
- package/scripts/build-brand-assets.mjs +126 -0
- package/shell/src/SidebarUpgradeCard.tsx +52 -0
- package/shell/src/index.ts +2 -0
- package/src/brand/AppMark.tsx +62 -0
- package/src/brand/index.ts +20 -0
- package/src/brand/svg.ts +234 -0
- package/src/index.ts +14 -2
- package/src/marketing/PricingModal.tsx +236 -6
- package/src/marketing/marketing-classes.ts +12 -0
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Shared React 19 web UI. Source-exported: no build step.
|
|
|
4
4
|
|
|
5
5
|
| Import | What it is |
|
|
6
6
|
| --- | --- |
|
|
7
|
-
| `@vvedantb/ui` | The only JavaScript entry. Primitives (Radix-based), AI elements, audio (`playChime`, `useReplyChime`, `playTone`), `toast` and `SonnerToaster`, the composer (`InputArea`, deprecated 0.3 `Composer`), shell (`SidebarHeader`, `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. |
|
|
7
|
+
| `@vvedantb/ui` | The only 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/tokens` | `tokens.css`: colour, radius, shadow, ease and font tokens for Tailwind v4. CSS. |
|
|
9
9
|
| `@vvedantb/ui/tokens/tokens.css` | Same file as `@vvedantb/ui/tokens`. CSS. |
|
|
10
10
|
| `@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`. |
|
|
@@ -179,6 +179,17 @@ function MentionComposer({ draft, setDraft, send }: Props) {
|
|
|
179
179
|
|
|
180
180
|
Fixture: `composer/examples/` (see its README). It has a `tokens.css` page and a `compat-shadcn-rgb.css` page, each with light and dark.
|
|
181
181
|
|
|
182
|
+
## Sidebar upgrade card
|
|
183
|
+
|
|
184
|
+
```tsx
|
|
185
|
+
import { SidebarUpgradeCard } from "@vvedantb/ui";
|
|
186
|
+
|
|
187
|
+
{!isPro && <SidebarUpgradeCard onUpgrade={() => setPricingOpen(true)} />}
|
|
188
|
+
{!isPro && <SidebarUpgradeCard collapsed onUpgrade={() => setPricingOpen(true)} />}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Shows the Project V mark, "Upgrade to Pro" and an "Upgrade" pill. `collapsed` renders an icon-only round button labelled "Upgrade to Pro" for narrow rails. The card does not check the plan, so the app hides it for Pro users.
|
|
192
|
+
|
|
182
193
|
## Sessions hover card
|
|
183
194
|
|
|
184
195
|
```tsx
|
|
@@ -865,22 +876,104 @@ links under a separator.
|
|
|
865
876
|
| `legalLinks` | `{ href, label }[]`, open in a new tab. |
|
|
866
877
|
| `variant` | `"muted"` (default, `text-muted`), `"muted-foreground"`, or `"touch"` (`text-muted-foreground`, `max-sm:hit-target` links, safe-area bottom padding). |
|
|
867
878
|
|
|
879
|
+
### `AppMark`
|
|
880
|
+
|
|
881
|
+
Family app icons: `app` (`projectv`, `vibot`, `vhub`, `vmem`, `verve`, `velth`),
|
|
882
|
+
`variant`, `size`, `title`.
|
|
883
|
+
|
|
884
|
+
- `variant="mark"` (default) is the plain logo in `currentColor`. It is cropped
|
|
885
|
+
tight to the glyph (stroke marks include half the stroke width) with no
|
|
886
|
+
padding. `size` is the rendered height; the width follows the glyph's aspect
|
|
887
|
+
ratio, so it sits level with a wordmark. A string `size` (for example `"2rem"`)
|
|
888
|
+
sets both width and height to that value. `APP_MARK_VIEWBOX` holds the tight
|
|
889
|
+
viewBox per app; `appMarkSvg()` and `APP_MARK_SVG` use it.
|
|
890
|
+
- `variant="tile"` and `"tile-light"` keep the equalised, padded 1024 grid on a
|
|
891
|
+
rounded tile, always square. `appTileSvg`, `appAdaptiveSvg`, `appExtension128Svg`,
|
|
892
|
+
`APP_TILE_SVG`, `APP_TILE_LIGHT_SVG` and favicons are unchanged.
|
|
893
|
+
|
|
894
|
+
Use tiles only where the app shows on its background (app and store icons,
|
|
895
|
+
favicons). Use the plain mark wherever the logo has no background.
|
|
896
|
+
|
|
868
897
|
### `PricingModal`
|
|
869
898
|
|
|
870
|
-
|
|
871
|
-
`
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
`
|
|
876
|
-
|
|
877
|
-
|
|
899
|
+
Upgrade dialog with two layouts. Without `plans` it is the single-plan dialog
|
|
900
|
+
below. With `plans` (0.22.0) it is the wide multi-plan dialog.
|
|
901
|
+
|
|
902
|
+
Shared props: `open`, `onClose`, `title` (accessible, hidden), `description`
|
|
903
|
+
(hidden), `tone` (`"muted"` default or `"muted-foreground"`),
|
|
904
|
+
`billingPeriod`, `onBillingPeriodChange`, `defaultBillingPeriod` (`"month"`
|
|
905
|
+
default) and `renderCheckout`.
|
|
906
|
+
|
|
907
|
+
#### Multi-plan (0.22.0)
|
|
908
|
+
|
|
909
|
+
Header: `apps` (an `AppName[]`, shown as plain `AppMark` logos, no tile, at the top left),
|
|
910
|
+
`headline` (centre), a Monthly / Annual switch and a close button (top right).
|
|
911
|
+
Then one card per entry of `plans` (2 to 4 fit) and one `finePrint` line. Cards
|
|
912
|
+
stack on narrow screens.
|
|
913
|
+
|
|
914
|
+
A `PricingPlan` is `{ id, name, price: { month, year }, ctaLabel?, intro?,
|
|
915
|
+
highlight?: { title, description }, benefits?: { label, icon?: "check" |
|
|
916
|
+
AppName, muted? }[], current?, featured?, periodSuffix? }`. Prices are display
|
|
917
|
+
strings and always per month (`periodSuffix` default `"/month"`; the yearly
|
|
918
|
+
price is the per-month price). `ctaLabel` defaults to "Get started". `featured`
|
|
919
|
+
(or `highlighted`) adds a subtle ring. A `current` plan shows a disabled,
|
|
920
|
+
outlined "Current plan" button and does not call `renderCheckout`.
|
|
921
|
+
|
|
922
|
+
The `featured` plan's button is a solid primary button (`Button` `variant="default"`). Other plans use `secondary`.
|
|
923
|
+
|
|
924
|
+
`renderCheckout(button, plan, period)` wraps each plan's button.
|
|
925
|
+
|
|
926
|
+
Each app passes its own plan names, prices and copy. The example and the screenshots use placeholder text only.
|
|
927
|
+
|
|
928
|
+
```tsx
|
|
929
|
+
<PricingModal
|
|
930
|
+
open={open}
|
|
931
|
+
onClose={close}
|
|
932
|
+
title="Plans"
|
|
933
|
+
apps={["vibot", "verve", "velth", "vmem", "vhub"]}
|
|
934
|
+
headline="One plan. Every V app."
|
|
935
|
+
finePrint="Cancel anytime. $120 billed yearly. Save 33%."
|
|
936
|
+
plans={[
|
|
937
|
+
{ id: "free", name: "Free", price: { month: "$0", year: "$0" }, current: true },
|
|
938
|
+
{
|
|
939
|
+
id: "pro",
|
|
940
|
+
name: "V Pro",
|
|
941
|
+
price: { month: "$15", year: "$10" },
|
|
942
|
+
featured: true,
|
|
943
|
+
intro: "Everything in Free, and:",
|
|
944
|
+
highlight: { title: "Shared", description: "usage across every V app" },
|
|
945
|
+
benefits: [
|
|
946
|
+
{ label: "Higher limits", icon: "vibot" },
|
|
947
|
+
{ label: "Pro features", icon: "verve" },
|
|
948
|
+
{ label: "Pro features", icon: "velth" },
|
|
949
|
+
{ label: "More memory", icon: "vmem" },
|
|
950
|
+
{ label: "More storage", icon: "vhub" },
|
|
951
|
+
],
|
|
952
|
+
},
|
|
953
|
+
]}
|
|
954
|
+
renderCheckout={(button, plan, period) => (
|
|
955
|
+
<CheckoutButton planId={plan.id} planPeriod={period}>{button}</CheckoutButton>
|
|
956
|
+
)}
|
|
957
|
+
/>
|
|
958
|
+
```
|
|
959
|
+
|
|
960
|
+
In the example, V Pro costs $15/month, or $10/month billed yearly ($120/year,
|
|
961
|
+
a 33% saving). The cards show the per-month price only, so the yearly total and
|
|
962
|
+
the saving go in `finePrint`. Screenshots: `brand/pricing-modal/` (regenerate with
|
|
963
|
+
`node scripts/build-pricing-screenshots.mjs`).
|
|
964
|
+
|
|
965
|
+
#### Single plan
|
|
966
|
+
|
|
967
|
+
Props: `logo` (`dark` and `light` image URLs, shown in that colour mode),
|
|
968
|
+
`wordmark`, `price`, `tagline`, `taglineFontClassName`, `features`, `ctaLabel`
|
|
969
|
+
(default "Get Pro"), `renderCheckout(button, period)` (wrap the button in the
|
|
970
|
+
billing provider's checkout), `closeButton` (an extra compact close button,
|
|
971
|
+
default `false`) and, when `description` is set, the title is an `sr-only`
|
|
972
|
+
`DialogTitle`, otherwise it sits in `VisuallyHidden`.
|
|
878
973
|
|
|
879
974
|
From 0.20.0, `yearly={{ pricePerMonth, total?, saveLabel? }}` adds a
|
|
880
|
-
monthly/yearly toggle; `price` stays the monthly price.
|
|
881
|
-
`
|
|
882
|
-
`defaultBillingPeriod` (`"month"` default). `renderCheckout` gets the selected
|
|
883
|
-
`period` (`"month"` or `"year"`) as its second argument, for Clerk's
|
|
975
|
+
monthly/yearly toggle; `price` stays the monthly price. `renderCheckout` gets the
|
|
976
|
+
selected `period` (`"month"` or `"year"`) as its second argument, for Clerk's
|
|
884
977
|
`planPeriod`. Without `yearly` the DOM is unchanged.
|
|
885
978
|
|
|
886
979
|
```tsx
|
|
@@ -894,6 +987,15 @@ monthly/yearly toggle; `price` stays the monthly price. Control the period with
|
|
|
894
987
|
/>
|
|
895
988
|
```
|
|
896
989
|
|
|
990
|
+
#### Migration
|
|
991
|
+
|
|
992
|
+
Single-plan callers need no change. To move to the multi-plan layout, replace
|
|
993
|
+
`logo`, `wordmark`, `price`, `tagline`, `features` and `yearly` with `plans`,
|
|
994
|
+
`apps` and `headline`, and change `renderCheckout` to
|
|
995
|
+
`(button, plan, period)`. Type helpers: use `PricingModalLegacyProps` or
|
|
996
|
+
`PricingModalMultiProps` when you need one layout's props;
|
|
997
|
+
`PricingModalProps` is their union.
|
|
998
|
+
|
|
897
999
|
### Account menu items and rail collapse tile
|
|
898
1000
|
|
|
899
1001
|
`SidebarAccountMenu` takes three optional props. Without them it renders as before.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vvedantb/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Shared React 19 UI for web apps: one 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), design-token CSS, shared Tailwind v4 CSS and a Tailwind preset.",
|
|
6
6
|
"type": "module",
|
|
@@ -40,6 +40,10 @@
|
|
|
40
40
|
"types": "./tokens/src/tailwind-preset.d.mts",
|
|
41
41
|
"default": "./tokens/src/tailwind-preset.mjs"
|
|
42
42
|
},
|
|
43
|
+
"./brand": {
|
|
44
|
+
"types": "./src/brand/index.ts",
|
|
45
|
+
"default": "./src/brand/index.ts"
|
|
46
|
+
},
|
|
43
47
|
"./landing.css": "./src/marketing/landing.css",
|
|
44
48
|
"./package.json": "./package.json"
|
|
45
49
|
},
|
|
@@ -50,6 +54,7 @@
|
|
|
50
54
|
"app/src",
|
|
51
55
|
"tokens/src",
|
|
52
56
|
"tokens/examples",
|
|
57
|
+
"scripts/build-brand-assets.mjs",
|
|
53
58
|
"README.md",
|
|
54
59
|
"LICENSE",
|
|
55
60
|
"!**/*.test.ts",
|
|
@@ -66,6 +71,8 @@
|
|
|
66
71
|
"test": "node tokens/scripts/check-component-tokens.mjs && vitest run && node --test tokens/test/*.test.mjs app/tests-node/*.test.mjs",
|
|
67
72
|
"verify": "node tokens/scripts/check-component-tokens.mjs && node tokens/verify/run.mjs",
|
|
68
73
|
"analyze": "node tokens/verify/analyze.mjs",
|
|
74
|
+
"brand:assets": "node scripts/build-brand-assets.mjs",
|
|
75
|
+
"brand:sheet": "node scripts/build-brand-sheet.mjs",
|
|
69
76
|
"prepack": "node tokens/scripts/check-exports.mjs"
|
|
70
77
|
},
|
|
71
78
|
"dependencies": {
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Writes the favicon, PWA, extension and Android icon sets for the family apps.
|
|
2
|
+
//
|
|
3
|
+
// node scripts/build-brand-assets.mjs --out <dir> [--app <name>] [--no-png] [--dry-run]
|
|
4
|
+
//
|
|
5
|
+
// SVGs are always written. PNGs and favicon.ico need `rsvg-convert`; without it they are skipped
|
|
6
|
+
// with a message. Layout: <dir>/<app>/{mark,icon,favicon,icon-light}.svg, PNGs, extension/, android/.
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { APP_NAMES, appAdaptiveSvg, appExtension128Svg, appMarkSvg, appTileSvg } from "../src/brand/svg.ts";
|
|
12
|
+
|
|
13
|
+
/** Whether `rsvg-convert` can be run. */
|
|
14
|
+
export function hasRsvg() {
|
|
15
|
+
return spawnSync("rsvg-convert", ["--version"], { stdio: "ignore" }).status === 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Renders `svg` to a square PNG buffer of `px` pixels. */
|
|
19
|
+
export function renderPng(svg, px) {
|
|
20
|
+
const result = spawnSync("rsvg-convert", ["-w", String(px), "-h", String(px), "-f", "png"], { input: svg, maxBuffer: 64 * 1024 * 1024 });
|
|
21
|
+
if (result.status !== 0) throw new Error(`rsvg-convert failed: ${String(result.stderr)}`);
|
|
22
|
+
return result.stdout;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** An ICO file that embeds each PNG (sizes in pixels, same order). */
|
|
26
|
+
export function icoFromPngs(pngs, sizes) {
|
|
27
|
+
const header = Buffer.alloc(6 + 16 * pngs.length);
|
|
28
|
+
header.writeUInt16LE(1, 2);
|
|
29
|
+
header.writeUInt16LE(pngs.length, 4);
|
|
30
|
+
let offset = header.length;
|
|
31
|
+
pngs.forEach((png, i) => {
|
|
32
|
+
const at = 6 + 16 * i;
|
|
33
|
+
header.writeUInt8(sizes[i] % 256, at);
|
|
34
|
+
header.writeUInt8(sizes[i] % 256, at + 1);
|
|
35
|
+
header.writeUInt16LE(1, at + 4);
|
|
36
|
+
header.writeUInt16LE(32, at + 6);
|
|
37
|
+
header.writeUInt32LE(png.length, at + 8);
|
|
38
|
+
header.writeUInt32LE(offset, at + 12);
|
|
39
|
+
offset += png.length;
|
|
40
|
+
});
|
|
41
|
+
return Buffer.concat([header, ...pngs]);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** SVG files for one app, as `[relative path, content]`. */
|
|
45
|
+
export function svgFiles(app) {
|
|
46
|
+
const tile = appTileSvg(app);
|
|
47
|
+
return [
|
|
48
|
+
["mark.svg", appMarkSvg(app)],
|
|
49
|
+
["icon.svg", tile],
|
|
50
|
+
["favicon.svg", tile],
|
|
51
|
+
["icon-light.svg", appTileSvg(app, "light")],
|
|
52
|
+
["android/adaptive-foreground.svg", appAdaptiveSvg(app, "#fff")],
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** PNG files for one app, as `[relative path, svg source, pixels]`. */
|
|
57
|
+
export function pngSpecs(app) {
|
|
58
|
+
const tile = appTileSvg(app);
|
|
59
|
+
const square = appTileSvg(app, "dark", 0);
|
|
60
|
+
return [
|
|
61
|
+
["favicon-16.png", tile, 16],
|
|
62
|
+
["favicon-32.png", tile, 32],
|
|
63
|
+
["apple-touch-icon.png", square, 180],
|
|
64
|
+
["icon-192.png", tile, 192],
|
|
65
|
+
["icon-512.png", tile, 512],
|
|
66
|
+
["icon-1024.png", tile, 1024],
|
|
67
|
+
["icon-light.png", appTileSvg(app, "light"), 512],
|
|
68
|
+
["maskable-512.png", square, 512],
|
|
69
|
+
["extension/icon-16.png", tile, 16],
|
|
70
|
+
["extension/icon-32.png", tile, 32],
|
|
71
|
+
["extension/icon-48.png", tile, 48],
|
|
72
|
+
["extension/icon-128.png", appExtension128Svg(app), 128],
|
|
73
|
+
["android/adaptive-foreground-432.png", appAdaptiveSvg(app, "#fff"), 432],
|
|
74
|
+
["android/adaptive-monochrome-432.png", appAdaptiveSvg(app, "#000"), 432],
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Writes (or, with `dryRun`, lists) the set for each app. Returns the relative file paths per app and any skip notes. */
|
|
79
|
+
export function buildBrandAssets({ out, apps = APP_NAMES, png = true, dryRun = false }) {
|
|
80
|
+
const written = [];
|
|
81
|
+
const notes = [];
|
|
82
|
+
const withPng = png && hasRsvg();
|
|
83
|
+
if (png && !withPng) notes.push("rsvg-convert not found: PNGs and favicon.ico skipped (SVGs written).");
|
|
84
|
+
const put = (file, data) => {
|
|
85
|
+
written.push(file);
|
|
86
|
+
if (dryRun) return;
|
|
87
|
+
mkdirSync(dirname(join(out, file)), { recursive: true });
|
|
88
|
+
writeFileSync(join(out, file), data);
|
|
89
|
+
};
|
|
90
|
+
for (const app of apps) {
|
|
91
|
+
for (const [name, svg] of svgFiles(app)) put(join(app, name), svg);
|
|
92
|
+
if (!withPng) continue;
|
|
93
|
+
for (const [name, svg, px] of pngSpecs(app)) put(join(app, name), dryRun ? "" : renderPng(svg, px));
|
|
94
|
+
const sizes = [16, 32, 48];
|
|
95
|
+
const tile = appTileSvg(app);
|
|
96
|
+
put(join(app, "favicon.ico"), dryRun ? "" : icoFromPngs(sizes.map((px) => renderPng(tile, px)), sizes));
|
|
97
|
+
}
|
|
98
|
+
return { written, notes };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function parseArgs(argv) {
|
|
102
|
+
const args = { out: "", app: "", png: true, dryRun: false };
|
|
103
|
+
for (let i = 0; i < argv.length; i++) {
|
|
104
|
+
const arg = argv[i];
|
|
105
|
+
if (arg === "--out") args.out = argv[++i] ?? "";
|
|
106
|
+
else if (arg === "--app") args.app = argv[++i] ?? "";
|
|
107
|
+
else if (arg === "--no-png") args.png = false;
|
|
108
|
+
else if (arg === "--dry-run") args.dryRun = true;
|
|
109
|
+
else throw new Error(`unknown argument: ${arg}`);
|
|
110
|
+
}
|
|
111
|
+
if (!args.out) throw new Error("--out <dir> is required");
|
|
112
|
+
if (args.app && !APP_NAMES.includes(args.app)) throw new Error(`--app must be one of ${APP_NAMES.join(", ")}`);
|
|
113
|
+
return args;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
117
|
+
try {
|
|
118
|
+
const args = parseArgs(process.argv.slice(2));
|
|
119
|
+
const { written, notes } = buildBrandAssets({ out: args.out, apps: args.app ? [args.app] : APP_NAMES, png: args.png, dryRun: args.dryRun });
|
|
120
|
+
for (const file of written) console.log(`${args.dryRun ? "would write" : "wrote"} ${join(args.out, file)}`);
|
|
121
|
+
for (const note of notes) console.warn(note);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
console.error(error instanceof Error ? error.message : error);
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { AppMark } from "../../src/brand/AppMark";
|
|
2
|
+
import { Button } from "../../src/ui/button";
|
|
3
|
+
import { cn } from "../../src/utils/cn";
|
|
4
|
+
|
|
5
|
+
export type SidebarUpgradeCardProps = {
|
|
6
|
+
/** Opens the app's `PricingModal`. */
|
|
7
|
+
onUpgrade: () => void;
|
|
8
|
+
/** Narrow rail: an icon-only "Upgrade to Pro" button instead of the full card. Default `false`. */
|
|
9
|
+
collapsed?: boolean;
|
|
10
|
+
className?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const pillClass = "bg-foreground text-background hover:bg-foreground/85";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Upgrade prompt for the sidebar: Project V mark, "Upgrade to Pro" and an "Upgrade" pill. It does not check
|
|
17
|
+
* the plan; the app hides it when the user is already on Pro. The mark uses a numeric `size` (its height) so
|
|
18
|
+
* the width follows the aspect ratio.
|
|
19
|
+
*/
|
|
20
|
+
export function SidebarUpgradeCard({ onUpgrade, collapsed = false, className }: SidebarUpgradeCardProps) {
|
|
21
|
+
if (collapsed) {
|
|
22
|
+
return (
|
|
23
|
+
<Button
|
|
24
|
+
type="button"
|
|
25
|
+
size="icon"
|
|
26
|
+
aria-label="Upgrade to Pro"
|
|
27
|
+
title="Upgrade to Pro"
|
|
28
|
+
onClick={onUpgrade}
|
|
29
|
+
className={cn("rounded-full", pillClass, className)}
|
|
30
|
+
>
|
|
31
|
+
<AppMark app="projectv" size={16} />
|
|
32
|
+
</Button>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
return (
|
|
36
|
+
<div
|
|
37
|
+
data-slot="sidebar-upgrade-card"
|
|
38
|
+
className={cn(
|
|
39
|
+
"flex items-center justify-between gap-3 rounded-2xl bg-surface-secondary px-3 py-2.5 text-foreground",
|
|
40
|
+
className,
|
|
41
|
+
)}
|
|
42
|
+
>
|
|
43
|
+
<div className="flex min-w-0 items-center gap-2 text-sm font-semibold">
|
|
44
|
+
<AppMark app="projectv" size={14} className="shrink-0" />
|
|
45
|
+
<span className="truncate">Upgrade to Pro</span>
|
|
46
|
+
</div>
|
|
47
|
+
<Button type="button" size="sm" onClick={onUpgrade} className={cn("shrink-0 rounded-full", pillClass)}>
|
|
48
|
+
Upgrade
|
|
49
|
+
</Button>
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
package/shell/src/index.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { SidebarHeader } from "./SidebarHeader.js";
|
|
2
2
|
export type { SidebarHeaderProps } from "./SidebarHeader.js";
|
|
3
|
+
export { SidebarUpgradeCard } from "./SidebarUpgradeCard.js";
|
|
4
|
+
export type { SidebarUpgradeCardProps } from "./SidebarUpgradeCard.js";
|
|
3
5
|
export { PillTabs } from "./PillTabs.js";
|
|
4
6
|
export type { PillTabItem, PillTabsProps } from "./PillTabs.js";
|
|
5
7
|
export { cn } from "./cn.js";
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { useId, type SVGProps } from "react";
|
|
2
|
+
import { APP_MARKS, APP_MARK_VIEWBOX, MARK_VIEWBOX, TILE_RADIUS, markAspect, markLinecap, markStrokeWidth, type AppMarkVariant, type AppName } from "./svg";
|
|
3
|
+
|
|
4
|
+
export type AppMarkProps = Omit<SVGProps<SVGSVGElement>, "children" | "height" | "width" | "viewBox"> & {
|
|
5
|
+
/** Which family app's mark to draw. */
|
|
6
|
+
app: AppName;
|
|
7
|
+
/** `"mark"` (default): plain logo in `currentColor`, cropped tight to the glyph with no padding. `"tile"`: white glyph on a black rounded tile. `"tile-light"`: black glyph on a white tile. */
|
|
8
|
+
variant?: AppMarkVariant;
|
|
9
|
+
/**
|
|
10
|
+
* Default 24. For `"mark"` it sets the rendered height and the width follows the glyph's aspect ratio
|
|
11
|
+
* (a number gives an exact width; a string sets both to the same value and the glyph is centred).
|
|
12
|
+
* Tiles are always square, `size` by `size`.
|
|
13
|
+
*/
|
|
14
|
+
size?: number | string;
|
|
15
|
+
/** Accessible name. Without it the mark is `aria-hidden`. */
|
|
16
|
+
title?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const TILE_TONES = {
|
|
20
|
+
tile: { bg: "#000", fg: "#fff" },
|
|
21
|
+
"tile-light": { bg: "#fff", fg: "#000" },
|
|
22
|
+
} as const;
|
|
23
|
+
|
|
24
|
+
/** One of the six family app marks. The plain mark is cropped tight; tiles use the padded 1024 grid. */
|
|
25
|
+
export function AppMark({ app, variant = "mark", size = 24, title, ...rest }: AppMarkProps) {
|
|
26
|
+
const titleId = useId();
|
|
27
|
+
const { kind, paths } = APP_MARKS[app];
|
|
28
|
+
const isMark = variant === "mark";
|
|
29
|
+
const width = isMark && typeof size === "number" ? Math.round(size * markAspect(app) * 100) / 100 : size;
|
|
30
|
+
const tone = variant === "mark" ? undefined : TILE_TONES[variant];
|
|
31
|
+
const color = tone?.fg ?? "currentColor";
|
|
32
|
+
const glyphProps =
|
|
33
|
+
kind === "fill"
|
|
34
|
+
? { fill: color }
|
|
35
|
+
: { fill: "none", stroke: color, strokeWidth: markStrokeWidth(app), strokeLinecap: markLinecap(app), strokeLinejoin: "round" };
|
|
36
|
+
const glyph = (
|
|
37
|
+
<g {...(glyphProps as SVGProps<SVGGElement>)} data-glyph={app}>
|
|
38
|
+
{paths.map((d) => (
|
|
39
|
+
<path key={d} d={d} />
|
|
40
|
+
))}
|
|
41
|
+
</g>
|
|
42
|
+
);
|
|
43
|
+
return (
|
|
44
|
+
<svg
|
|
45
|
+
{...rest}
|
|
46
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
47
|
+
width={width}
|
|
48
|
+
height={size}
|
|
49
|
+
viewBox={isMark ? APP_MARK_VIEWBOX[app] : MARK_VIEWBOX}
|
|
50
|
+
preserveAspectRatio="xMidYMid meet"
|
|
51
|
+
data-app={app}
|
|
52
|
+
data-variant={variant}
|
|
53
|
+
role={title ? "img" : undefined}
|
|
54
|
+
aria-hidden={title ? undefined : true}
|
|
55
|
+
aria-labelledby={title ? titleId : undefined}
|
|
56
|
+
>
|
|
57
|
+
{title ? <title id={titleId}>{title}</title> : null}
|
|
58
|
+
{tone ? <rect width={1024} height={1024} rx={TILE_RADIUS} fill={tone.bg} /> : null}
|
|
59
|
+
{glyph}
|
|
60
|
+
</svg>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export { AppMark } from "./AppMark";
|
|
2
|
+
export type { AppMarkProps } from "./AppMark";
|
|
3
|
+
export {
|
|
4
|
+
APP_MARKS,
|
|
5
|
+
APP_MARK_VIEWBOX,
|
|
6
|
+
APP_MARK_SVG,
|
|
7
|
+
APP_NAMES,
|
|
8
|
+
APP_TILE_LIGHT_SVG,
|
|
9
|
+
APP_TILE_SVG,
|
|
10
|
+
appAdaptiveSvg,
|
|
11
|
+
appExtension128Svg,
|
|
12
|
+
appMarkSvg,
|
|
13
|
+
appTileSvg,
|
|
14
|
+
markAspect,
|
|
15
|
+
markBBox,
|
|
16
|
+
markLinecap,
|
|
17
|
+
markStrokeWidth,
|
|
18
|
+
pathBBox,
|
|
19
|
+
} from "./svg";
|
|
20
|
+
export type { AppMarkVariant, AppName, MarkShape } from "./svg";
|
package/src/brand/svg.ts
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// Geometry and raw SVG strings for the family app marks. No imports, so the
|
|
2
|
+
// asset script can load this file directly with Node.
|
|
3
|
+
|
|
4
|
+
export const APP_NAMES = ["projectv", "vibot", "vhub", "vmem", "verve", "velth"] as const;
|
|
5
|
+
|
|
6
|
+
export type AppName = (typeof APP_NAMES)[number];
|
|
7
|
+
|
|
8
|
+
/** `mark`: glyph only, cropped tight, follows `currentColor`. `tile`: white glyph on a black tile. `tile-light`: black glyph on a white tile. */
|
|
9
|
+
export type AppMarkVariant = "mark" | "tile" | "tile-light";
|
|
10
|
+
|
|
11
|
+
export const MARK_VIEWBOX = "0 0 1024 1024";
|
|
12
|
+
export const MARK_STROKE_WIDTH = 50;
|
|
13
|
+
export const TILE_RADIUS = 224;
|
|
14
|
+
|
|
15
|
+
export type MarkShape = {
|
|
16
|
+
/** Filled shapes (no stroke) versus lines (50 wide, round caps by default). */
|
|
17
|
+
kind: "fill" | "stroke";
|
|
18
|
+
/** Path data on the 1024 grid. */
|
|
19
|
+
paths: readonly string[];
|
|
20
|
+
/** Stroke width override on the 1024 grid. Defaults to `MARK_STROKE_WIDTH`. */
|
|
21
|
+
strokeWidth?: number;
|
|
22
|
+
/** Line cap override. Defaults to `"round"`. */
|
|
23
|
+
linecap?: "butt" | "round";
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Stroke width of a mark on the 1024 grid. */
|
|
27
|
+
export function markStrokeWidth(app: AppName): number {
|
|
28
|
+
return APP_MARKS[app].strokeWidth ?? MARK_STROKE_WIDTH;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Line cap of a mark. */
|
|
32
|
+
export function markLinecap(app: AppName): "butt" | "round" {
|
|
33
|
+
return APP_MARKS[app].linecap ?? "round";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Glyphs sized by one rule: longest side = 512 * min(1.2, aspect^0.25), centred halfway between
|
|
38
|
+
* the bounding-box centre and the centre of mass. `verve` and `velth` are the final vectors.
|
|
39
|
+
* The others are traced from the approved 1024px renders.
|
|
40
|
+
*/
|
|
41
|
+
export const APP_MARKS: Record<AppName, MarkShape> = {
|
|
42
|
+
projectv: {
|
|
43
|
+
kind: "fill",
|
|
44
|
+
paths: ["M172 398L260 397L418 558L580 377L858 378L824 415L604 415L452 598L466 611L622 433L808 433L773 471L658 471L491 671L442 672Z"],
|
|
45
|
+
},
|
|
46
|
+
vibot: {
|
|
47
|
+
kind: "fill",
|
|
48
|
+
paths: [
|
|
49
|
+
"M295 314L396 314L396 574C520 540 620 440 655 314L711 314C680 470 560 590 396 627L390 627L390 651L396 651C560 610 700 480 734 314L789 314C740 540 560 712 295 712Z",
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
vhub: {
|
|
53
|
+
kind: "stroke",
|
|
54
|
+
paths: ["M211 512L743 512", "M415 512C500 512 540 363 743 363", "M415 512C500 512 540 661 743 661"],
|
|
55
|
+
},
|
|
56
|
+
vmem: {
|
|
57
|
+
kind: "stroke",
|
|
58
|
+
strokeWidth: 58,
|
|
59
|
+
linecap: "butt",
|
|
60
|
+
paths: [
|
|
61
|
+
"M693.2 379.5A227.25 227.25 0 0 1 581.0 732.7",
|
|
62
|
+
"M442.5 732.7A227.25 227.25 0 0 1 330.9 378.7",
|
|
63
|
+
"M397.3 319.9A227.25 227.25 0 0 1 627.1 320.4",
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
verve: {
|
|
67
|
+
kind: "stroke",
|
|
68
|
+
paths: [
|
|
69
|
+
"M285.4 344.1Q428.6 344.1 512 552.6Q595.4 344.1 738.6 344.1",
|
|
70
|
+
"M285.4 344.1L285.4 579.8Q428.6 579.8 512 706.7Q595.4 579.8 738.6 579.8L738.6 344.1",
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
velth: {
|
|
74
|
+
kind: "stroke",
|
|
75
|
+
paths: [
|
|
76
|
+
"M512 732.3C379.8 652.1 289.7 471.9 299.7 301.6C419.9 371.7 500 552 512 732.3Z",
|
|
77
|
+
"M512 732.3C644.2 652.1 734.3 471.9 724.3 301.6C604.1 371.7 524 552 512 732.3Z",
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
type Point = [number, number];
|
|
83
|
+
type Box = [minX: number, minY: number, maxX: number, maxY: number];
|
|
84
|
+
|
|
85
|
+
/** Roots in (0, 1) of the derivative of a Bezier with 1D control values `v` (quadratic or cubic). */
|
|
86
|
+
function bezierExtrema(v: number[]): number[] {
|
|
87
|
+
const roots: number[] = [];
|
|
88
|
+
if (v.length === 3) {
|
|
89
|
+
const d = v[0]! - 2 * v[1]! + v[2]!;
|
|
90
|
+
if (d !== 0) roots.push((v[0]! - v[1]!) / d);
|
|
91
|
+
} else {
|
|
92
|
+
const a = -v[0]! + 3 * v[1]! - 3 * v[2]! + v[3]!;
|
|
93
|
+
const b = 2 * (v[0]! - 2 * v[1]! + v[2]!);
|
|
94
|
+
const c = v[1]! - v[0]!;
|
|
95
|
+
if (Math.abs(a) < 1e-12) {
|
|
96
|
+
if (b !== 0) roots.push(-c / b);
|
|
97
|
+
} else {
|
|
98
|
+
const disc = b * b - 4 * a * c;
|
|
99
|
+
if (disc >= 0) roots.push((-b + Math.sqrt(disc)) / (2 * a), (-b - Math.sqrt(disc)) / (2 * a));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return roots.filter((t) => t > 0 && t < 1);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function bezierAt(v: number[], t: number): number {
|
|
106
|
+
const u = 1 - t;
|
|
107
|
+
const [p0, p1, p2, p3] = v as [number, number, number, number];
|
|
108
|
+
return v.length === 3 ? u * u * p0 + 2 * u * t * p1 + t * t * p2 : u ** 3 * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t ** 3 * p3;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Extreme points of an SVG elliptical arc (endpoint form, no rotation), including both ends. */
|
|
112
|
+
function arcPoints(from: Point, [rx0, ry0, , large, sweep, x, y]: [number, number, number, number, number, number, number]): Point[] {
|
|
113
|
+
const [x0, y0] = from;
|
|
114
|
+
let rx = Math.abs(rx0);
|
|
115
|
+
let ry = Math.abs(ry0);
|
|
116
|
+
const dx = (x0 - x) / 2;
|
|
117
|
+
const dy = (y0 - y) / 2;
|
|
118
|
+
const lambda = (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry);
|
|
119
|
+
if (lambda > 1) {
|
|
120
|
+
rx *= Math.sqrt(lambda);
|
|
121
|
+
ry *= Math.sqrt(lambda);
|
|
122
|
+
}
|
|
123
|
+
const num = rx * rx * ry * ry - rx * rx * dy * dy - ry * ry * dx * dx;
|
|
124
|
+
const den = rx * rx * dy * dy + ry * ry * dx * dx;
|
|
125
|
+
const k = (large === sweep ? -1 : 1) * Math.sqrt(Math.max(0, num / den));
|
|
126
|
+
const cx = k * ((rx * dy) / ry) + (x0 + x) / 2;
|
|
127
|
+
const cy = k * (-(ry * dx) / rx) + (y0 + y) / 2;
|
|
128
|
+
const a0 = Math.atan2((y0 - cy) / ry, (x0 - cx) / rx);
|
|
129
|
+
let delta = Math.atan2((y - cy) / ry, (x - cx) / rx) - a0;
|
|
130
|
+
if (sweep && delta < 0) delta += 2 * Math.PI;
|
|
131
|
+
if (!sweep && delta > 0) delta -= 2 * Math.PI;
|
|
132
|
+
const points: Point[] = [from, [x, y]];
|
|
133
|
+
for (let q = -6; q <= 6; q++) {
|
|
134
|
+
const a = (q * Math.PI) / 2;
|
|
135
|
+
const t = (a - a0) / delta;
|
|
136
|
+
if (t > 0 && t < 1) points.push([cx + rx * Math.cos(a), cy + ry * Math.sin(a)]);
|
|
137
|
+
}
|
|
138
|
+
return points;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Exact bounding box of absolute `M`, `L`, `Q`, `C` and `A` path data (the only commands the marks use). Returns [minX, minY, maxX, maxY]. */
|
|
142
|
+
export function pathBBox(d: string): Box {
|
|
143
|
+
const tokens = d.match(/[MLQCA]|-?\d*\.?\d+(?:e-?\d+)?/g) ?? [];
|
|
144
|
+
const points: Point[] = [];
|
|
145
|
+
let cmd = "";
|
|
146
|
+
let cur: Point = [0, 0];
|
|
147
|
+
const argc: Record<string, number> = { M: 2, L: 2, Q: 4, C: 6, A: 7 };
|
|
148
|
+
for (let i = 0; i < tokens.length; ) {
|
|
149
|
+
if (/[A-Z]/.test(tokens[i]!)) cmd = tokens[i++]!;
|
|
150
|
+
const n = argc[cmd];
|
|
151
|
+
if (!n) throw new Error(`Unsupported path command: ${cmd}`);
|
|
152
|
+
const a = tokens.slice(i, i + n).map(Number);
|
|
153
|
+
i += n;
|
|
154
|
+
const end: Point = [a[n - 2]!, a[n - 1]!];
|
|
155
|
+
if (cmd === "Q" || cmd === "C") {
|
|
156
|
+
const xs = [cur[0], ...a.filter((_, j) => j % 2 === 0)];
|
|
157
|
+
const ys = [cur[1], ...a.filter((_, j) => j % 2 === 1)];
|
|
158
|
+
for (const t of new Set([...bezierExtrema(xs), ...bezierExtrema(ys)])) points.push([bezierAt(xs, t), bezierAt(ys, t)]);
|
|
159
|
+
} else if (cmd === "A") {
|
|
160
|
+
points.push(...arcPoints(cur, a as Parameters<typeof arcPoints>[1]));
|
|
161
|
+
}
|
|
162
|
+
points.push(end);
|
|
163
|
+
cur = end;
|
|
164
|
+
}
|
|
165
|
+
return [Math.min(...points.map((p) => p[0])), Math.min(...points.map((p) => p[1])), Math.max(...points.map((p) => p[0])), Math.max(...points.map((p) => p[1]))];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Tight box of a mark: union of its paths, grown by half the stroke width for stroke marks. Returns [minX, minY, maxX, maxY]. */
|
|
169
|
+
export function markBBox(app: AppName): Box {
|
|
170
|
+
const { kind, paths } = APP_MARKS[app];
|
|
171
|
+
const boxes = paths.map(pathBBox);
|
|
172
|
+
const pad = kind === "stroke" ? markStrokeWidth(app) / 2 : 0;
|
|
173
|
+
return [Math.min(...boxes.map((b) => b[0])) - pad, Math.min(...boxes.map((b) => b[1])) - pad, Math.max(...boxes.map((b) => b[2])) + pad, Math.max(...boxes.map((b) => b[3])) + pad];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Tight viewBox per app (`minX minY width height` on the 1024 grid): the glyph box grown by half the
|
|
179
|
+
* stroke width for stroke marks, rounded outward to whole units. Checked against `markBBox` in tests.
|
|
180
|
+
* Used by the plain logo (`variant="mark"`, `appMarkSvg`). Tiles keep `MARK_VIEWBOX`.
|
|
181
|
+
*/
|
|
182
|
+
export const APP_MARK_VIEWBOX: Record<AppName, string> = {
|
|
183
|
+
projectv: "172 377 686 295",
|
|
184
|
+
vibot: "295 314 494 398",
|
|
185
|
+
vhub: "186 338 582 348",
|
|
186
|
+
vmem: "255 259 513 503",
|
|
187
|
+
verve: "260 319 504 413",
|
|
188
|
+
velth: "273 276 478 482",
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/** Width over height of a mark's tight viewBox. */
|
|
192
|
+
export function markAspect(app: AppName): number {
|
|
193
|
+
const [, , w = 1, h = 1] = APP_MARK_VIEWBOX[app].split(" ").map(Number);
|
|
194
|
+
return w / h;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const NS = 'xmlns="http://www.w3.org/2000/svg"';
|
|
198
|
+
|
|
199
|
+
/** Attributes that paint the glyph in `color`. */
|
|
200
|
+
export function markPaintAttrs(app: AppName, color: string): string {
|
|
201
|
+
return APP_MARKS[app].kind === "fill" ? `fill="${color}"` : `fill="none" stroke="${color}" stroke-width="${markStrokeWidth(app)}" stroke-linecap="${markLinecap(app)}" stroke-linejoin="round"`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function pathElements(app: AppName): string {
|
|
205
|
+
return APP_MARKS[app].paths.map((d) => `<path d="${d}"/>`).join("");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Glyph only, cropped tight with no padding, painted with `color` (default `currentColor`). */
|
|
209
|
+
export function appMarkSvg(app: AppName, color = "currentColor"): string {
|
|
210
|
+
return `<svg ${NS} viewBox="${APP_MARK_VIEWBOX[app]}" ${markPaintAttrs(app, color)}>${pathElements(app)}</svg>\n`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Tile with a glyph. `radius` 0 gives a full-bleed square (apple-touch, maskable). */
|
|
214
|
+
export function appTileSvg(app: AppName, tone: "dark" | "light" = "dark", radius = TILE_RADIUS): string {
|
|
215
|
+
const [bg, fg] = tone === "dark" ? ["#000", "#fff"] : ["#fff", "#000"];
|
|
216
|
+
const rx = radius > 0 ? ` rx="${radius}"` : "";
|
|
217
|
+
return `<svg ${NS} viewBox="${MARK_VIEWBOX}"><rect width="1024" height="1024"${rx} fill="${bg}"/><g ${markPaintAttrs(app, fg)}>${pathElements(app)}</g></svg>\n`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Android adaptive-icon layer: 432 canvas, glyph scaled into the 288 inner square. */
|
|
221
|
+
export function appAdaptiveSvg(app: AppName, color: string): string {
|
|
222
|
+
const scale = 288 / 1024;
|
|
223
|
+
return `<svg ${NS} viewBox="0 0 432 432"><g transform="translate(216 216) scale(${scale}) translate(-512 -512)" ${markPaintAttrs(app, color)}>${pathElements(app)}</g></svg>\n`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Chrome extension 128px icon: 96px tile plus 16px transparent padding. */
|
|
227
|
+
export function appExtension128Svg(app: AppName): string {
|
|
228
|
+
const inner = appTileSvg(app).replace(`<svg ${NS} viewBox="${MARK_VIEWBOX}">`, `<svg ${NS} x="16" y="16" width="96" height="96" viewBox="${MARK_VIEWBOX}">`);
|
|
229
|
+
return `<svg ${NS} viewBox="0 0 128 128">${inner.trim()}</svg>\n`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export const APP_MARK_SVG = Object.fromEntries(APP_NAMES.map((app) => [app, appMarkSvg(app)])) as Record<AppName, string>;
|
|
233
|
+
export const APP_TILE_SVG = Object.fromEntries(APP_NAMES.map((app) => [app, appTileSvg(app)])) as Record<AppName, string>;
|
|
234
|
+
export const APP_TILE_LIGHT_SVG = Object.fromEntries(APP_NAMES.map((app) => [app, appTileSvg(app, "light")])) as Record<AppName, string>;
|
package/src/index.ts
CHANGED
|
@@ -143,7 +143,7 @@ export * from "./ai-elements/sources";
|
|
|
143
143
|
export * from "./ai-elements/speech-input";
|
|
144
144
|
export * from "./ai-elements/suggestion";
|
|
145
145
|
|
|
146
|
-
// Agent activity and run surfaces (moved from
|
|
146
|
+
// Agent activity and run surfaces (moved from an app): the activity timeline and
|
|
147
147
|
// its step model, Trace lines, plan, test results, web preview, context usage
|
|
148
148
|
// and the model picker.
|
|
149
149
|
export * from "./ai-elements/activity-shared";
|
|
@@ -181,6 +181,8 @@ export { toast } from "sonner";
|
|
|
181
181
|
// use the root `cn` (clsx + tailwind-merge).
|
|
182
182
|
export { SidebarHeader } from "../shell/src/SidebarHeader";
|
|
183
183
|
export type { SidebarHeaderProps } from "../shell/src/SidebarHeader";
|
|
184
|
+
export { SidebarUpgradeCard } from "../shell/src/SidebarUpgradeCard";
|
|
185
|
+
export type { SidebarUpgradeCardProps } from "../shell/src/SidebarUpgradeCard";
|
|
184
186
|
export { PillTabs } from "../shell/src/PillTabs";
|
|
185
187
|
export type { PillTabItem, PillTabsProps } from "../shell/src/PillTabs";
|
|
186
188
|
export { SessionsHoverCard } from "../shell/src/SessionsHoverCard";
|
|
@@ -289,11 +291,21 @@ export type { PricingModalTone, SiteFooterVariant } from "./marketing/marketing-
|
|
|
289
291
|
export { SiteFooter } from "./marketing/SiteFooter";
|
|
290
292
|
export type { SiteFooterLink, SiteFooterProps, SiteFooterSocial } from "./marketing/SiteFooter";
|
|
291
293
|
export { PricingModal } from "./marketing/PricingModal";
|
|
292
|
-
export type {
|
|
294
|
+
export type {
|
|
295
|
+
PricingModalLegacyProps,
|
|
296
|
+
PricingModalMultiProps,
|
|
297
|
+
PricingModalPeriod,
|
|
298
|
+
PricingModalProps,
|
|
299
|
+
PricingPlan,
|
|
300
|
+
PricingPlanBenefit,
|
|
301
|
+
} from "./marketing/PricingModal";
|
|
293
302
|
|
|
294
303
|
// App helpers: confirm skip, update watcher, settings chrome, stable auth, Convex provider, VITE env and ensure-user.
|
|
295
304
|
export * from "../app/src/index";
|
|
296
305
|
|
|
306
|
+
// Family app marks (AppMark and raw SVG strings for favicons).
|
|
307
|
+
export * from "./brand";
|
|
308
|
+
|
|
297
309
|
// Native tokens (JS values that match tokens.css).
|
|
298
310
|
export { tokens } from "../tokens/src/native";
|
|
299
311
|
export type { Tokens } from "../tokens/src/native";
|
|
@@ -1,13 +1,17 @@
|
|
|
1
|
-
import { useState, type ReactElement, type ReactNode } from "react";
|
|
1
|
+
import { useRef, useState, type ReactElement, type ReactNode } from "react";
|
|
2
2
|
import { IconCheck, IconX } from "@tabler/icons-react";
|
|
3
|
+
import { AppMark } from "../brand/AppMark";
|
|
4
|
+
import type { AppName } from "../brand/svg";
|
|
3
5
|
import { Button } from "../ui/button";
|
|
4
6
|
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "../ui/dialog";
|
|
5
7
|
import { VisuallyHidden } from "../ui/visually-hidden";
|
|
6
|
-
import { pricingModalClasses, type PricingModalTone } from "./marketing-classes";
|
|
8
|
+
import { pricingModalClasses, pricingMultiClasses, type PricingModalTone } from "./marketing-classes";
|
|
7
9
|
|
|
8
10
|
export type PricingModalPeriod = "month" | "year";
|
|
9
11
|
|
|
10
|
-
export type
|
|
12
|
+
export type PricingModalLegacyProps = {
|
|
13
|
+
/** Absent in the single-plan layout. Set `plans` for the multi-plan layout. */
|
|
14
|
+
plans?: undefined;
|
|
11
15
|
open: boolean;
|
|
12
16
|
/** Called when the dialog asks to close and by the `closeButton`. */
|
|
13
17
|
onClose: () => void;
|
|
@@ -51,8 +55,67 @@ export type PricingModalProps = {
|
|
|
51
55
|
tone?: PricingModalTone;
|
|
52
56
|
};
|
|
53
57
|
|
|
54
|
-
|
|
55
|
-
|
|
58
|
+
export type PricingPlanBenefit = {
|
|
59
|
+
label: ReactNode;
|
|
60
|
+
/** `"check"` (default) or an app mark. */
|
|
61
|
+
icon?: "check" | AppName;
|
|
62
|
+
/** Dimmed row. */
|
|
63
|
+
muted?: boolean;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type PricingPlan = {
|
|
67
|
+
id: string;
|
|
68
|
+
name: string;
|
|
69
|
+
/** Display strings per month, for example `{ month: "$10", year: "$8" }`. The yearly price is still per month. */
|
|
70
|
+
price: { month: string; year: string };
|
|
71
|
+
/** Default "Get started". */
|
|
72
|
+
ctaLabel?: string;
|
|
73
|
+
/** Line above the highlight, for example "Everything in Free, and:". */
|
|
74
|
+
intro?: ReactNode;
|
|
75
|
+
/** Large title with small text under it. */
|
|
76
|
+
highlight?: { title: string; description: string };
|
|
77
|
+
benefits?: readonly PricingPlanBenefit[];
|
|
78
|
+
/** The plan the user is on: the button reads "Current plan" and is disabled. */
|
|
79
|
+
current?: boolean;
|
|
80
|
+
/** Subtle ring on the card. `highlighted` is an alias. */
|
|
81
|
+
featured?: boolean;
|
|
82
|
+
highlighted?: boolean;
|
|
83
|
+
/** Text after the price. Default "/month". */
|
|
84
|
+
periodSuffix?: string;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export type PricingModalMultiProps = {
|
|
88
|
+
open: boolean;
|
|
89
|
+
onClose: () => void;
|
|
90
|
+
/** Accessible dialog title. Not visible. */
|
|
91
|
+
title: string;
|
|
92
|
+
/** Accessible description. Not visible. */
|
|
93
|
+
description?: string;
|
|
94
|
+
/** Cards, left to right. Sized for 1 to 4. */
|
|
95
|
+
plans: readonly PricingPlan[];
|
|
96
|
+
/** App marks at the top left. */
|
|
97
|
+
apps?: readonly AppName[];
|
|
98
|
+
/** Centred headline, for example "One plan. Every V app." */
|
|
99
|
+
headline?: ReactNode;
|
|
100
|
+
/** Small print under the cards. */
|
|
101
|
+
finePrint?: ReactNode;
|
|
102
|
+
billingPeriod?: PricingModalPeriod;
|
|
103
|
+
onBillingPeriodChange?: (period: PricingModalPeriod) => void;
|
|
104
|
+
/** Default `"month"`. */
|
|
105
|
+
defaultBillingPeriod?: PricingModalPeriod;
|
|
106
|
+
/** Wraps each plan's button, for example in the billing provider's checkout. Not called for the `current` plan. */
|
|
107
|
+
renderCheckout: (button: ReactElement, plan: PricingPlan, period: PricingModalPeriod) => ReactNode;
|
|
108
|
+
tone?: PricingModalTone;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export type PricingModalProps = PricingModalLegacyProps | PricingModalMultiProps;
|
|
112
|
+
|
|
113
|
+
/** Upgrade dialog. With `plans`: wide multi-plan layout. Without: the single-plan layout. */
|
|
114
|
+
export function PricingModal(props: PricingModalProps) {
|
|
115
|
+
return props.plans ? <PricingModalMulti {...props} /> : <PricingModalSingle {...props} />;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function PricingModalSingle({
|
|
56
119
|
open,
|
|
57
120
|
onClose,
|
|
58
121
|
title,
|
|
@@ -71,7 +134,7 @@ export function PricingModal({
|
|
|
71
134
|
renderCheckout,
|
|
72
135
|
closeButton = false,
|
|
73
136
|
tone = "muted",
|
|
74
|
-
}:
|
|
137
|
+
}: PricingModalLegacyProps) {
|
|
75
138
|
const c = pricingModalClasses[tone];
|
|
76
139
|
const [localPeriod, setLocalPeriod] = useState<PricingModalPeriod>(defaultBillingPeriod);
|
|
77
140
|
const period: PricingModalPeriod = yearly ? (billingPeriod ?? localPeriod) : "month";
|
|
@@ -174,3 +237,170 @@ export function PricingModal({
|
|
|
174
237
|
</Dialog>
|
|
175
238
|
);
|
|
176
239
|
}
|
|
240
|
+
|
|
241
|
+
const multiWidth = {
|
|
242
|
+
1: "sm:max-w-md",
|
|
243
|
+
2: "sm:max-w-3xl",
|
|
244
|
+
3: "sm:max-w-5xl",
|
|
245
|
+
4: "sm:max-w-6xl",
|
|
246
|
+
} as const;
|
|
247
|
+
|
|
248
|
+
const multiColumns = {
|
|
249
|
+
1: "",
|
|
250
|
+
2: "sm:grid-cols-2",
|
|
251
|
+
3: "md:grid-cols-3",
|
|
252
|
+
4: "sm:grid-cols-2 lg:grid-cols-4",
|
|
253
|
+
} as const;
|
|
254
|
+
|
|
255
|
+
function PricingModalMulti({
|
|
256
|
+
open,
|
|
257
|
+
onClose,
|
|
258
|
+
title,
|
|
259
|
+
description,
|
|
260
|
+
plans,
|
|
261
|
+
apps,
|
|
262
|
+
headline,
|
|
263
|
+
finePrint,
|
|
264
|
+
billingPeriod,
|
|
265
|
+
onBillingPeriodChange,
|
|
266
|
+
defaultBillingPeriod = "month",
|
|
267
|
+
renderCheckout,
|
|
268
|
+
tone = "muted",
|
|
269
|
+
}: PricingModalMultiProps) {
|
|
270
|
+
const c = pricingMultiClasses[tone];
|
|
271
|
+
const [localPeriod, setLocalPeriod] = useState<PricingModalPeriod>(defaultBillingPeriod);
|
|
272
|
+
const period = billingPeriod ?? localPeriod;
|
|
273
|
+
const selectPeriod = (next: PricingModalPeriod) => {
|
|
274
|
+
setLocalPeriod(next);
|
|
275
|
+
onBillingPeriodChange?.(next);
|
|
276
|
+
};
|
|
277
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
278
|
+
const count = Math.min(Math.max(plans.length, 1), 4) as 1 | 2 | 3 | 4;
|
|
279
|
+
return (
|
|
280
|
+
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
|
|
281
|
+
<DialogContent
|
|
282
|
+
ref={contentRef}
|
|
283
|
+
hideCloseButton
|
|
284
|
+
// Focus the dialog, not the first segment button, so no ring shows on open.
|
|
285
|
+
onOpenAutoFocus={(event) => {
|
|
286
|
+
event.preventDefault();
|
|
287
|
+
contentRef.current?.focus();
|
|
288
|
+
}}
|
|
289
|
+
className={`max-h-[calc(100dvh-2rem)] gap-0 overflow-y-auto p-0 font-sans sm:w-[calc(100vw-2rem)] ${multiWidth[count]}`}
|
|
290
|
+
>
|
|
291
|
+
<DialogTitle className="sr-only">{title}</DialogTitle>
|
|
292
|
+
{description ? <DialogDescription className="sr-only">{description}</DialogDescription> : null}
|
|
293
|
+
|
|
294
|
+
<div className="grid grid-cols-[1fr_auto] items-center gap-x-4 gap-y-3 px-4 pb-4 pt-5 sm:grid-cols-[1fr_auto_1fr] sm:px-6">
|
|
295
|
+
<div className="flex items-center gap-2 text-foreground" data-testid="pricing-apps">
|
|
296
|
+
{apps?.map((app) => <AppMark key={app} app={app} size={20} />)}
|
|
297
|
+
</div>
|
|
298
|
+
<div className="flex items-center justify-end gap-2 sm:order-last">
|
|
299
|
+
<div role="group" aria-label="Billing period" className="inline-flex rounded-full bg-surface-tertiary p-0.5">
|
|
300
|
+
{(["month", "year"] as const).map((value) => (
|
|
301
|
+
<button
|
|
302
|
+
key={value}
|
|
303
|
+
type="button"
|
|
304
|
+
aria-pressed={period === value}
|
|
305
|
+
onClick={() => selectPeriod(value)}
|
|
306
|
+
className={`rounded-full px-3 py-1 text-xs font-medium transition-colors duration-150 motion-reduce:transition-none ${
|
|
307
|
+
period === value ? "bg-segment text-segment-foreground" : `${c.switchIdle} hover:text-foreground`
|
|
308
|
+
}`}
|
|
309
|
+
>
|
|
310
|
+
{value === "month" ? "Monthly" : "Annual"}
|
|
311
|
+
</button>
|
|
312
|
+
))}
|
|
313
|
+
</div>
|
|
314
|
+
<button
|
|
315
|
+
type="button"
|
|
316
|
+
onClick={onClose}
|
|
317
|
+
className="flex size-8 items-center justify-center rounded-full text-muted-foreground transition-[background-color,transform] duration-100 ease-out hover:bg-surface-tertiary hover:text-foreground active:scale-[0.96] motion-reduce:transition-none"
|
|
318
|
+
>
|
|
319
|
+
<IconX className="size-4" />
|
|
320
|
+
<span className="sr-only">Close</span>
|
|
321
|
+
</button>
|
|
322
|
+
</div>
|
|
323
|
+
{headline ? (
|
|
324
|
+
<p className="col-span-2 text-balance text-center text-xl font-medium tracking-tight text-foreground sm:order-none sm:col-span-1 sm:text-2xl">
|
|
325
|
+
{headline}
|
|
326
|
+
</p>
|
|
327
|
+
) : (
|
|
328
|
+
<span className="hidden sm:block" />
|
|
329
|
+
)}
|
|
330
|
+
</div>
|
|
331
|
+
|
|
332
|
+
<div className={`grid grid-cols-1 gap-3 px-4 sm:px-6 ${multiColumns[count]}`}>
|
|
333
|
+
{plans.map((plan) => {
|
|
334
|
+
const emphasised = plan.featured ?? plan.highlighted;
|
|
335
|
+
const button = (
|
|
336
|
+
<Button
|
|
337
|
+
size="lg"
|
|
338
|
+
variant={plan.current ? "outline" : emphasised ? "default" : "secondary"}
|
|
339
|
+
disabled={plan.current}
|
|
340
|
+
className="h-12 w-full rounded-full"
|
|
341
|
+
>
|
|
342
|
+
{plan.current ? "Current plan" : (plan.ctaLabel ?? "Get started")}
|
|
343
|
+
</Button>
|
|
344
|
+
);
|
|
345
|
+
return (
|
|
346
|
+
<section
|
|
347
|
+
key={plan.id}
|
|
348
|
+
data-plan={plan.id}
|
|
349
|
+
aria-label={plan.name}
|
|
350
|
+
className={`flex flex-col rounded-3xl bg-surface-secondary p-5 ${
|
|
351
|
+
emphasised ? "ring-1 ring-foreground/25" : "ring-1 ring-separator"
|
|
352
|
+
}`}
|
|
353
|
+
>
|
|
354
|
+
{emphasised ? (
|
|
355
|
+
<div className="flex items-center gap-2 text-base text-foreground">
|
|
356
|
+
<AppMark app="projectv" size={16} className="shrink-0" />
|
|
357
|
+
<h3>{plan.name}</h3>
|
|
358
|
+
</div>
|
|
359
|
+
) : (
|
|
360
|
+
<h3 className="text-base text-foreground">{plan.name}</h3>
|
|
361
|
+
)}
|
|
362
|
+
<p className="mt-4 flex items-baseline gap-1">
|
|
363
|
+
<span className="text-5xl font-medium tabular-nums leading-none tracking-tight text-foreground">
|
|
364
|
+
{plan.price[period]}
|
|
365
|
+
</span>
|
|
366
|
+
<span className={c.suffix}>{plan.periodSuffix ?? "/month"}</span>
|
|
367
|
+
</p>
|
|
368
|
+
<div className="mt-5">{plan.current ? button : renderCheckout(button, plan, period)}</div>
|
|
369
|
+
{plan.intro ? <p className={`mt-6 text-sm ${c.suffix}`}>{plan.intro}</p> : null}
|
|
370
|
+
{plan.highlight ? (
|
|
371
|
+
<div className="mt-3">
|
|
372
|
+
<p className="text-2xl font-medium leading-tight text-foreground">{plan.highlight.title}</p>
|
|
373
|
+
<p className="text-sm text-foreground">{plan.highlight.description}</p>
|
|
374
|
+
</div>
|
|
375
|
+
) : null}
|
|
376
|
+
{plan.benefits?.length ? (
|
|
377
|
+
<ul className="mt-5 space-y-2.5 border-t border-separator pt-4">
|
|
378
|
+
{plan.benefits.map((benefit, index) => (
|
|
379
|
+
<li
|
|
380
|
+
key={index}
|
|
381
|
+
className={`flex items-start gap-2.5 text-sm leading-snug ${
|
|
382
|
+
benefit.muted ? c.suffix : "text-foreground"
|
|
383
|
+
}`}
|
|
384
|
+
>
|
|
385
|
+
<span className="mt-0.5 flex h-4 w-5 shrink-0 items-center justify-center">
|
|
386
|
+
{benefit.icon && benefit.icon !== "check" ? (
|
|
387
|
+
<AppMark app={benefit.icon} size={12} className="max-w-full" />
|
|
388
|
+
) : (
|
|
389
|
+
<IconCheck className="size-4" strokeWidth={2} />
|
|
390
|
+
)}
|
|
391
|
+
</span>
|
|
392
|
+
<span>{benefit.label}</span>
|
|
393
|
+
</li>
|
|
394
|
+
))}
|
|
395
|
+
</ul>
|
|
396
|
+
) : null}
|
|
397
|
+
</section>
|
|
398
|
+
);
|
|
399
|
+
})}
|
|
400
|
+
</div>
|
|
401
|
+
|
|
402
|
+
{finePrint ? <p className={`px-4 pb-5 pt-4 text-xs sm:px-6 ${c.suffix}`}>{finePrint}</p> : <div className="pb-4" />}
|
|
403
|
+
</DialogContent>
|
|
404
|
+
</Dialog>
|
|
405
|
+
);
|
|
406
|
+
}
|
|
@@ -113,3 +113,15 @@ export const pricingModalClasses = {
|
|
|
113
113
|
} as const;
|
|
114
114
|
|
|
115
115
|
export type PricingModalTone = keyof typeof pricingModalClasses;
|
|
116
|
+
|
|
117
|
+
/** Multi-plan `PricingModal` secondary text classes for the two tones. */
|
|
118
|
+
export const pricingMultiClasses = {
|
|
119
|
+
muted: {
|
|
120
|
+
suffix: "text-muted", // check-tokens-allow: text-muted
|
|
121
|
+
switchIdle: "text-muted", // check-tokens-allow: text-muted
|
|
122
|
+
},
|
|
123
|
+
"muted-foreground": {
|
|
124
|
+
suffix: "text-muted-foreground",
|
|
125
|
+
switchIdle: "text-muted-foreground",
|
|
126
|
+
},
|
|
127
|
+
} as const;
|