@vvedantb/ui 0.20.0 → 0.22.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
@@ -867,20 +867,84 @@ links under a separator.
867
867
 
868
868
  ### `PricingModal`
869
869
 
870
- One-plan upgrade dialog. Props: `open`, `onClose`, `title` (accessible, hidden),
871
- `description` (hidden; when set the title is an `sr-only` `DialogTitle`,
872
- otherwise it sits in `VisuallyHidden`), `logo` (`dark` and `light` image URLs,
873
- shown in that colour mode), `wordmark`, `price`, `tagline`,
874
- `taglineFontClassName`, `features`, `ctaLabel` (default "Get Pro"),
875
- `renderCheckout(button, period)` (wrap the button in the billing provider's checkout),
876
- `closeButton` (an extra compact close button, default `false`) and `tone`
877
- (`"muted"` default or `"muted-foreground"`).
870
+ Upgrade dialog with two layouts. Without `plans` it is the single-plan dialog
871
+ below. With `plans` (0.22.0) it is the wide multi-plan dialog.
872
+
873
+ Shared props: `open`, `onClose`, `title` (accessible, hidden), `description`
874
+ (hidden), `tone` (`"muted"` default or `"muted-foreground"`),
875
+ `billingPeriod`, `onBillingPeriodChange`, `defaultBillingPeriod` (`"month"`
876
+ default) and `renderCheckout`.
877
+
878
+ #### Multi-plan (0.22.0)
879
+
880
+ Header: `apps` (an `AppName[]`, shown as `AppMark` tiles at the top left),
881
+ `headline` (centre), a Monthly / Annual switch and a close button (top right).
882
+ Then one card per entry of `plans` (2 to 4 fit) and one `finePrint` line. Cards
883
+ stack on narrow screens.
884
+
885
+ A `PricingPlan` is `{ id, name, price: { month, year }, ctaLabel?, intro?,
886
+ highlight?: { title, description }, benefits?: { label, icon?: "check" |
887
+ AppName, muted? }[], current?, featured?, periodSuffix? }`. Prices are display
888
+ strings and always per month (`periodSuffix` default `"/month"`; the yearly
889
+ price is the per-month price). `ctaLabel` defaults to "Get started". `featured`
890
+ (or `highlighted`) adds a subtle ring. A `current` plan shows a disabled,
891
+ outlined "Current plan" button and does not call `renderCheckout`.
892
+
893
+ The `featured` plan's button is a solid primary button (`Button` `variant="default"`). Other plans use `secondary`.
894
+
895
+ `renderCheckout(button, plan, period)` wraps each plan's button.
896
+
897
+ Each app passes its own plan names, prices and copy. The example and the screenshots use placeholder text only.
898
+
899
+ ```tsx
900
+ <PricingModal
901
+ open={open}
902
+ onClose={close}
903
+ title="Plans"
904
+ apps={["vibot", "verve", "velth", "vmem", "vhub"]}
905
+ headline="One plan. Every V app."
906
+ finePrint="Cancel anytime. $120 billed yearly. Save 33%."
907
+ plans={[
908
+ { id: "free", name: "Free", price: { month: "$0", year: "$0" }, current: true },
909
+ {
910
+ id: "pro",
911
+ name: "V Pro",
912
+ price: { month: "$15", year: "$10" },
913
+ featured: true,
914
+ intro: "Everything in Free, and:",
915
+ highlight: { title: "Shared", description: "usage across every V app" },
916
+ benefits: [
917
+ { label: "Higher limits", icon: "vibot" },
918
+ { label: "Pro features", icon: "verve" },
919
+ { label: "Pro features", icon: "velth" },
920
+ { label: "More memory", icon: "vmem" },
921
+ { label: "More storage", icon: "vhub" },
922
+ ],
923
+ },
924
+ ]}
925
+ renderCheckout={(button, plan, period) => (
926
+ <CheckoutButton planId={plan.id} planPeriod={period}>{button}</CheckoutButton>
927
+ )}
928
+ />
929
+ ```
930
+
931
+ In the example, V Pro costs $15/month, or $10/month billed yearly ($120/year,
932
+ a 33% saving). The cards show the per-month price only, so the yearly total and
933
+ the saving go in `finePrint`. Screenshots: `brand/pricing-modal/` (regenerate with
934
+ `node scripts/build-pricing-screenshots.mjs`).
935
+
936
+ #### Single plan
937
+
938
+ Props: `logo` (`dark` and `light` image URLs, shown in that colour mode),
939
+ `wordmark`, `price`, `tagline`, `taglineFontClassName`, `features`, `ctaLabel`
940
+ (default "Get Pro"), `renderCheckout(button, period)` (wrap the button in the
941
+ billing provider's checkout), `closeButton` (an extra compact close button,
942
+ default `false`) and, when `description` is set, the title is an `sr-only`
943
+ `DialogTitle`, otherwise it sits in `VisuallyHidden`.
878
944
 
879
945
  From 0.20.0, `yearly={{ pricePerMonth, total?, saveLabel? }}` adds a
880
- monthly/yearly toggle; `price` stays the monthly price. Control the period with
881
- `billingPeriod` and `onBillingPeriodChange`, or set the start with
882
- `defaultBillingPeriod` (`"month"` default). `renderCheckout` gets the selected
883
- `period` (`"month"` or `"year"`) as its second argument, for Clerk's
946
+ monthly/yearly toggle; `price` stays the monthly price. `renderCheckout` gets the
947
+ selected `period` (`"month"` or `"year"`) as its second argument, for Clerk's
884
948
  `planPeriod`. Without `yearly` the DOM is unchanged.
885
949
 
886
950
  ```tsx
@@ -894,6 +958,15 @@ monthly/yearly toggle; `price` stays the monthly price. Control the period with
894
958
  />
895
959
  ```
896
960
 
961
+ #### Migration
962
+
963
+ Single-plan callers need no change. To move to the multi-plan layout, replace
964
+ `logo`, `wordmark`, `price`, `tagline`, `features` and `yearly` with `plans`,
965
+ `apps` and `headline`, and change `renderCheckout` to
966
+ `(button, plan, period)`. Type helpers: use `PricingModalLegacyProps` or
967
+ `PricingModalMultiProps` when you need one layout's props;
968
+ `PricingModalProps` is their union.
969
+
897
970
  ### Account menu items and rail collapse tile
898
971
 
899
972
  `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.20.0",
3
+ "version": "0.22.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,55 @@
1
+ import { useId, type SVGProps } from "react";
2
+ import { APP_MARKS, MARK_VIEWBOX, TILE_RADIUS, 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): glyph in `currentColor`. `"tile"`: white glyph on a black rounded tile. `"tile-light"`: black glyph on a white tile. */
8
+ variant?: AppMarkVariant;
9
+ /** Width and height. Default 24. */
10
+ size?: number | string;
11
+ /** Accessible name. Without it the mark is `aria-hidden`. */
12
+ title?: string;
13
+ };
14
+
15
+ const TILE_TONES = {
16
+ tile: { bg: "#000", fg: "#fff" },
17
+ "tile-light": { bg: "#fff", fg: "#000" },
18
+ } as const;
19
+
20
+ /** One of the six family app marks on the 1024 grid. */
21
+ export function AppMark({ app, variant = "mark", size = 24, title, ...rest }: AppMarkProps) {
22
+ const titleId = useId();
23
+ const { kind, paths } = APP_MARKS[app];
24
+ const tone = variant === "mark" ? undefined : TILE_TONES[variant];
25
+ const color = tone?.fg ?? "currentColor";
26
+ const glyphProps =
27
+ kind === "fill"
28
+ ? { fill: color }
29
+ : { fill: "none", stroke: color, strokeWidth: markStrokeWidth(app), strokeLinecap: markLinecap(app), strokeLinejoin: "round" };
30
+ const glyph = (
31
+ <g {...(glyphProps as SVGProps<SVGGElement>)} data-glyph={app}>
32
+ {paths.map((d) => (
33
+ <path key={d} d={d} />
34
+ ))}
35
+ </g>
36
+ );
37
+ return (
38
+ <svg
39
+ {...rest}
40
+ xmlns="http://www.w3.org/2000/svg"
41
+ width={size}
42
+ height={size}
43
+ viewBox={MARK_VIEWBOX}
44
+ data-app={app}
45
+ data-variant={variant}
46
+ role={title ? "img" : undefined}
47
+ aria-hidden={title ? undefined : true}
48
+ aria-labelledby={title ? titleId : undefined}
49
+ >
50
+ {title ? <title id={titleId}>{title}</title> : null}
51
+ {tone ? <rect width={1024} height={1024} rx={TILE_RADIUS} fill={tone.bg} /> : null}
52
+ {glyph}
53
+ </svg>
54
+ );
55
+ }
@@ -0,0 +1,16 @@
1
+ export { AppMark } from "./AppMark";
2
+ export type { AppMarkProps } from "./AppMark";
3
+ export {
4
+ APP_MARKS,
5
+ APP_MARK_SVG,
6
+ APP_NAMES,
7
+ APP_TILE_LIGHT_SVG,
8
+ APP_TILE_SVG,
9
+ appAdaptiveSvg,
10
+ appExtension128Svg,
11
+ appMarkSvg,
12
+ appTileSvg,
13
+ markLinecap,
14
+ markStrokeWidth,
15
+ } from "./svg";
16
+ export type { AppMarkVariant, AppName, MarkShape } from "./svg";
@@ -0,0 +1,119 @@
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, 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
+ const NS = 'xmlns="http://www.w3.org/2000/svg"';
83
+
84
+ /** Attributes that paint the glyph in `color`. */
85
+ export function markPaintAttrs(app: AppName, color: string): string {
86
+ return APP_MARKS[app].kind === "fill" ? `fill="${color}"` : `fill="none" stroke="${color}" stroke-width="${markStrokeWidth(app)}" stroke-linecap="${markLinecap(app)}" stroke-linejoin="round"`;
87
+ }
88
+
89
+ function pathElements(app: AppName): string {
90
+ return APP_MARKS[app].paths.map((d) => `<path d="${d}"/>`).join("");
91
+ }
92
+
93
+ /** Glyph only, painted with `color` (default `currentColor`). */
94
+ export function appMarkSvg(app: AppName, color = "currentColor"): string {
95
+ return `<svg ${NS} viewBox="${MARK_VIEWBOX}" ${markPaintAttrs(app, color)}>${pathElements(app)}</svg>\n`;
96
+ }
97
+
98
+ /** Tile with a glyph. `radius` 0 gives a full-bleed square (apple-touch, maskable). */
99
+ export function appTileSvg(app: AppName, tone: "dark" | "light" = "dark", radius = TILE_RADIUS): string {
100
+ const [bg, fg] = tone === "dark" ? ["#000", "#fff"] : ["#fff", "#000"];
101
+ const rx = radius > 0 ? ` rx="${radius}"` : "";
102
+ return `<svg ${NS} viewBox="${MARK_VIEWBOX}"><rect width="1024" height="1024"${rx} fill="${bg}"/><g ${markPaintAttrs(app, fg)}>${pathElements(app)}</g></svg>\n`;
103
+ }
104
+
105
+ /** Android adaptive-icon layer: 432 canvas, glyph scaled into the 288 inner square. */
106
+ export function appAdaptiveSvg(app: AppName, color: string): string {
107
+ const scale = 288 / 1024;
108
+ 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`;
109
+ }
110
+
111
+ /** Chrome extension 128px icon: 96px tile plus 16px transparent padding. */
112
+ export function appExtension128Svg(app: AppName): string {
113
+ const inner = appTileSvg(app).replace(`<svg ${NS} viewBox="${MARK_VIEWBOX}">`, `<svg ${NS} x="16" y="16" width="96" height="96" viewBox="${MARK_VIEWBOX}">`);
114
+ return `<svg ${NS} viewBox="0 0 128 128">${inner.trim()}</svg>\n`;
115
+ }
116
+
117
+ export const APP_MARK_SVG = Object.fromEntries(APP_NAMES.map((app) => [app, appMarkSvg(app)])) as Record<AppName, string>;
118
+ export const APP_TILE_SVG = Object.fromEntries(APP_NAMES.map((app) => [app, appTileSvg(app)])) as Record<AppName, string>;
119
+ 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 eva): the activity timeline and
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";
@@ -289,11 +289,21 @@ export type { PricingModalTone, SiteFooterVariant } from "./marketing/marketing-
289
289
  export { SiteFooter } from "./marketing/SiteFooter";
290
290
  export type { SiteFooterLink, SiteFooterProps, SiteFooterSocial } from "./marketing/SiteFooter";
291
291
  export { PricingModal } from "./marketing/PricingModal";
292
- export type { PricingModalPeriod, PricingModalProps } from "./marketing/PricingModal";
292
+ export type {
293
+ PricingModalLegacyProps,
294
+ PricingModalMultiProps,
295
+ PricingModalPeriod,
296
+ PricingModalProps,
297
+ PricingPlan,
298
+ PricingPlanBenefit,
299
+ } from "./marketing/PricingModal";
293
300
 
294
301
  // App helpers: confirm skip, update watcher, settings chrome, stable auth, Convex provider, VITE env and ensure-user.
295
302
  export * from "../app/src/index";
296
303
 
304
+ // Family app marks (AppMark and raw SVG strings for favicons).
305
+ export * from "./brand";
306
+
297
307
  // Native tokens (JS values that match tokens.css).
298
308
  export { tokens } from "../tokens/src/native";
299
309
  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 PricingModalProps = {
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
- /** Single-plan upgrade dialog: brand header with price and tagline, checkout button and feature list. */
55
- export function PricingModal({
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
- }: PricingModalProps) {
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,163 @@ 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} variant="tile" size={24} className="rounded-md" />)}
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
+ <h3 className="text-base text-foreground">{plan.name}</h3>
355
+ <p className="mt-4 flex items-baseline gap-1">
356
+ <span className="text-5xl font-medium tabular-nums leading-none tracking-tight text-foreground">
357
+ {plan.price[period]}
358
+ </span>
359
+ <span className={c.suffix}>{plan.periodSuffix ?? "/month"}</span>
360
+ </p>
361
+ <div className="mt-5">{plan.current ? button : renderCheckout(button, plan, period)}</div>
362
+ {plan.intro ? <p className={`mt-6 text-sm ${c.suffix}`}>{plan.intro}</p> : null}
363
+ {plan.highlight ? (
364
+ <div className="mt-3">
365
+ <p className="text-2xl font-medium leading-tight text-foreground">{plan.highlight.title}</p>
366
+ <p className="text-sm text-foreground">{plan.highlight.description}</p>
367
+ </div>
368
+ ) : null}
369
+ {plan.benefits?.length ? (
370
+ <ul className="mt-5 space-y-2.5 border-t border-separator pt-4">
371
+ {plan.benefits.map((benefit, index) => (
372
+ <li
373
+ key={index}
374
+ className={`flex items-start gap-2.5 text-sm leading-snug ${
375
+ benefit.muted ? c.suffix : "text-foreground"
376
+ }`}
377
+ >
378
+ <span className="mt-0.5 flex size-4 shrink-0 items-center justify-center">
379
+ {benefit.icon && benefit.icon !== "check" ? (
380
+ <AppMark app={benefit.icon} size={16} />
381
+ ) : (
382
+ <IconCheck className="size-4" strokeWidth={2} />
383
+ )}
384
+ </span>
385
+ <span>{benefit.label}</span>
386
+ </li>
387
+ ))}
388
+ </ul>
389
+ ) : null}
390
+ </section>
391
+ );
392
+ })}
393
+ </div>
394
+
395
+ {finePrint ? <p className={`px-4 pb-5 pt-4 text-xs sm:px-6 ${c.suffix}`}>{finePrint}</p> : <div className="pb-4" />}
396
+ </DialogContent>
397
+ </Dialog>
398
+ );
399
+ }
@@ -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;