@vvedantb/native-ui 0.2.1 → 0.3.13

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.
Files changed (91) hide show
  1. package/README.md +171 -9
  2. package/package.json +84 -2
  3. package/src/AlertDialog.tsx +73 -0
  4. package/src/AmbientBackground.tsx +26 -11
  5. package/src/Avatar.tsx +141 -0
  6. package/src/Badge.tsx +108 -0
  7. package/src/BottomSheetContainer.tsx +7 -1
  8. package/src/Button.tsx +153 -52
  9. package/src/Card.tsx +111 -36
  10. package/src/Checkbox.tsx +78 -0
  11. package/src/Chip.tsx +5 -3
  12. package/src/ConfirmDialog.tsx +105 -0
  13. package/src/DateTimeField.tsx +6 -2
  14. package/src/Dialog.tsx +253 -0
  15. package/src/EmptyState.tsx +122 -7
  16. package/src/FloatingFAB.tsx +2 -4
  17. package/src/FloatingSurface.tsx +31 -9
  18. package/src/FloatingTabBar.tsx +39 -11
  19. package/src/Input.tsx +15 -4
  20. package/src/Label.tsx +31 -0
  21. package/src/LabeledSwitchRow.tsx +96 -0
  22. package/src/LiquidGlass.tsx +2 -10
  23. package/src/ListRow.tsx +7 -2
  24. package/src/PageTitle.tsx +81 -0
  25. package/src/PillTabs.tsx +28 -14
  26. package/src/Progress.tsx +67 -0
  27. package/src/SearchInput.tsx +163 -0
  28. package/src/Select.tsx +32 -6
  29. package/src/Separator.tsx +38 -0
  30. package/src/Sheet.tsx +107 -0
  31. package/src/Skeleton.tsx +25 -5
  32. package/src/Spinner.tsx +92 -0
  33. package/src/StatusDot.tsx +66 -0
  34. package/src/Switch.tsx +24 -7
  35. package/src/Tabs.tsx +190 -0
  36. package/src/Textarea.tsx +47 -0
  37. package/src/Toast.tsx +170 -0
  38. package/src/blur.ts +48 -0
  39. package/src/brand/AppIcons.tsx +106 -0
  40. package/src/brand/AppMark.tsx +83 -0
  41. package/src/brand/AppWordmark.tsx +97 -0
  42. package/src/brand/familyApps.ts +24 -0
  43. package/src/brand/index.ts +34 -0
  44. package/src/brand/svg.ts +263 -0
  45. package/src/brand/vProAppBenefits.ts +22 -0
  46. package/src/controlStyles.ts +395 -0
  47. package/src/dialogLayout.ts +20 -0
  48. package/src/entries/ambient.ts +3 -0
  49. package/src/entries/bottom-sheet.ts +2 -0
  50. package/src/entries/brand.ts +2 -0
  51. package/src/entries/button.ts +3 -0
  52. package/src/entries/date-time.ts +3 -0
  53. package/src/entries/floating.ts +24 -0
  54. package/src/entries/layout.ts +10 -0
  55. package/src/entries/marketing.ts +10 -0
  56. package/src/entries/page-title.ts +4 -0
  57. package/src/entries/primitives.ts +99 -0
  58. package/src/entries/search-input.ts +4 -0
  59. package/src/entries/tab-bar.ts +10 -0
  60. package/src/entries/theme.ts +15 -0
  61. package/src/entries/toast.ts +6 -0
  62. package/src/examples/ParityGallery.tsx +513 -0
  63. package/src/floating.ts +5 -1
  64. package/src/glassBlur.tsx +95 -0
  65. package/src/gradient.ts +21 -0
  66. package/src/haptics.ts +22 -2
  67. package/src/index.ts +101 -2
  68. package/src/initials.ts +10 -0
  69. package/src/marketing/PricingModal.tsx +410 -0
  70. package/src/marketing/UpgradeToProCard.tsx +103 -0
  71. package/src/marketing/pricing.ts +76 -0
  72. package/src/palette.ts +43 -0
  73. package/src/primitives/button.tsx +3 -1
  74. package/src/primitives/card.tsx +5 -2
  75. package/src/primitives/input.tsx +9 -2
  76. package/src/primitives/native-only-animated-view.tsx +10 -3
  77. package/src/primitives/overlay.tsx +179 -0
  78. package/src/primitives/select.tsx +25 -12
  79. package/src/primitives/switch.tsx +12 -13
  80. package/src/primitives/text.tsx +25 -8
  81. package/src/primitives/use-controllable-state.ts +24 -0
  82. package/src/settings/SettingsField.tsx +11 -2
  83. package/src/settings/SettingsSection.tsx +20 -6
  84. package/src/settings/SettingsToggleRow.tsx +7 -2
  85. package/src/tabBarRoutes.ts +52 -0
  86. package/src/toastChrome.ts +103 -0
  87. package/src/toastStore.ts +86 -0
  88. package/src/tokens.ts +19 -14
  89. package/src/typography.ts +24 -0
  90. package/src/useReduceMotion.ts +24 -0
  91. package/src/useScheme.ts +6 -0
@@ -0,0 +1,97 @@
1
+ import { Pressable, Text, View } from "react-native";
2
+ import { cn } from "../cn";
3
+ import { tokens } from "../tokens";
4
+ import { WORDMARK } from "../typography";
5
+ import { useScheme } from "../useScheme";
6
+ import { APP_ICONS } from "./AppIcons";
7
+ import type { AppName } from "./svg";
8
+
9
+ /** The five product apps. `projectv` keeps its Michroma wordmark and never uses `AppWordmark`. */
10
+ export type ProductAppName = Exclude<AppName, "projectv">;
11
+
12
+ export type AppWordmarkProps = {
13
+ /** Which product app's name to show. The label is always the lowercase app id. */
14
+ app: ProductAppName;
15
+ /** Text font size in points (default 24). The icon is `Math.round(size * 1.4)` square and the gap is `0.2em`. */
16
+ size?: number;
17
+ /** `false` renders the name only (default `true`). */
18
+ icon?: boolean;
19
+ /** Icon and text colour. Default the scheme's `tokens.color.foreground`. */
20
+ color?: string;
21
+ /** When set, the wordmark is a pressable link (the web `href`). */
22
+ onPress?: () => void;
23
+ accessibilityLabel?: string;
24
+ className?: string;
25
+ testID?: string;
26
+ };
27
+
28
+ /**
29
+ * Wordmark for a product app: the `APP_ICONS` glyph, then the lowercase name in
30
+ * Instrument Sans 500 (`font-instrument-sans-medium`) with -0.02em letter
31
+ * spacing, as the web `AppWordmark`. No serif, no italic.
32
+ */
33
+ export function AppWordmark({
34
+ app,
35
+ size = 24,
36
+ icon = true,
37
+ color,
38
+ onPress,
39
+ accessibilityLabel,
40
+ className,
41
+ testID,
42
+ }: AppWordmarkProps) {
43
+ const scheme = useScheme();
44
+ const Icon = APP_ICONS[app];
45
+ const fg = color ?? tokens.color.foreground[scheme];
46
+ const content = (
47
+ <>
48
+ {icon ? (
49
+ <Icon size={Math.round(size * WORDMARK.iconScale)} color={fg} />
50
+ ) : null}
51
+ <Text
52
+ className={WORDMARK.fontClass}
53
+ style={{
54
+ fontSize: size,
55
+ lineHeight: size,
56
+ letterSpacing: size * WORDMARK.letterSpacingEm,
57
+ // The class alone draws Regular where an app maps every Instrument
58
+ // Sans class to one family.
59
+ fontWeight: WORDMARK.fontWeight,
60
+ color: fg,
61
+ includeFontPadding: false,
62
+ }}
63
+ >
64
+ {app}
65
+ </Text>
66
+ </>
67
+ );
68
+ const rowClassName = cn("flex-row items-center", className);
69
+ const rowStyle = { gap: size * WORDMARK.gapEm };
70
+
71
+ if (onPress) {
72
+ return (
73
+ <Pressable
74
+ testID={testID}
75
+ accessibilityRole="link"
76
+ accessibilityLabel={accessibilityLabel ?? app}
77
+ onPress={onPress}
78
+ className={cn(rowClassName, "active:opacity-70")}
79
+ style={rowStyle}
80
+ >
81
+ {content}
82
+ </Pressable>
83
+ );
84
+ }
85
+ return (
86
+ <View
87
+ testID={testID}
88
+ accessible
89
+ accessibilityLabel={accessibilityLabel ?? app}
90
+ className={rowClassName}
91
+ style={rowStyle}
92
+ >
93
+ {content}
94
+ </View>
95
+ );
96
+ }
97
+ AppWordmark.displayName = "AppWordmark";
@@ -0,0 +1,24 @@
1
+ import type { AppName } from "./svg";
2
+
3
+ export type FamilyApp = {
4
+ app: AppName;
5
+ /** Display name. */
6
+ name: string;
7
+ /** Home page of the app. */
8
+ url: string;
9
+ };
10
+
11
+ /** Single source for family app display names and sites, in display order. */
12
+ export const FAMILY_APPS: readonly FamilyApp[] = [
13
+ { app: "vibot", name: "vibot", url: "https://vibot.projectv.uk" },
14
+ { app: "verve", name: "Verve", url: "https://verve.projectv.uk" },
15
+ { app: "velth", name: "Velth", url: "https://velth.projectv.uk" },
16
+ { app: "vmem", name: "vmem", url: "https://vmem.projectv.uk" },
17
+ { app: "vhub", name: "vhub", url: "https://vhub.projectv.uk" },
18
+ { app: "projectv", name: "Project V", url: "https://projectv.uk" },
19
+ ];
20
+
21
+ /** Entry of `FAMILY_APPS` for an app. */
22
+ export function familyApp(app: AppName): FamilyApp {
23
+ return FAMILY_APPS.find((entry) => entry.app === app)!;
24
+ }
@@ -0,0 +1,34 @@
1
+ // `svg.ts`, `familyApps.ts` and `vProAppBenefits.ts` are byte-identical copies of
2
+ // `@vvedantb/ui` `src/brand/` (pure data, no imports beyond each other). A test
3
+ // in this package fails when they differ: change both together.
4
+ export { APP_ICONS, IconProjectV, IconVelth, IconVerve, IconVhub, IconVibot, IconVmem } from "./AppIcons";
5
+ export type { AppIconComponent, AppIconProps } from "./AppIcons";
6
+ export { AppMark } from "./AppMark";
7
+ export type { AppMarkProps } from "./AppMark";
8
+ export { AppWordmark } from "./AppWordmark";
9
+ export type { AppWordmarkProps, ProductAppName } from "./AppWordmark";
10
+ export {
11
+ APP_MARKS,
12
+ APP_MARK_CROP_VIEWBOX,
13
+ APP_MARK_VIEWBOX,
14
+ APP_MARK_VIEWBOX_START,
15
+ PROJECTV_MARK_VIEWBOX,
16
+ APP_MARK_SVG,
17
+ APP_NAMES,
18
+ APP_TILE_LIGHT_SVG,
19
+ APP_TILE_SVG,
20
+ appAdaptiveSvg,
21
+ appExtension128Svg,
22
+ appMarkSvg,
23
+ appTileSvg,
24
+ markAspect,
25
+ markBBox,
26
+ markLinecap,
27
+ markStrokeWidth,
28
+ pathBBox,
29
+ } from "./svg";
30
+ export type { AppMarkVariant, AppName, MarkShape } from "./svg";
31
+ export { FAMILY_APPS, familyApp } from "./familyApps";
32
+ export type { FamilyApp } from "./familyApps";
33
+ export { V_PRO_APP_BENEFITS } from "./vProAppBenefits";
34
+ export type { PricingPlanAppBenefit } from "./vProAppBenefits";
@@ -0,0 +1,263 @@
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 on an equalised square viewBox, 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
+ * Frame size for the five app glyphs (`vibot`, `vhub`, `vmem`, `verve`, `velth`) in the plain logo (`variant="mark"`,
179
+ * `appMarkSvg`, `Icon*`): a 629 x 629 square, the union of their `markBBox` results on the 1024 tile grid
180
+ * (x 186 to 789, y 259.96 to 761.7) at side max(width, height) x 1.04 (about 4% padding), rounded outward.
181
+ * All five use this side, so glyph scale stays equal. Only the size is shared: each app has its own origin in
182
+ * `APP_MARK_VIEWBOX`. `projectv` is left out so its wide brand mark does not shrink the app glyphs.
183
+ */
184
+ export const APP_MARK_CROP_VIEWBOX = "173 197 629 629";
185
+
186
+ /**
187
+ * `projectv` (a brand mark, not an app) keeps a 687 x 687 frame, centred on `markBBox("projectv")`
188
+ * (x 172 to 858, y 377 to 672, centre 515 x 524.5), with no extra padding.
189
+ */
190
+ export const PROJECTV_MARK_VIEWBOX = "171.5 181 687 687";
191
+
192
+ /**
193
+ * Plain-mark viewBox per app. Always square. The five apps keep one 629 side (equal scale), and each origin is
194
+ * `markBBox(app)` centre minus 314.5 on both axes, so every glyph sits centred in its own frame. `projectv` uses
195
+ * its 687 frame, centre minus 343.5. Hardcoded; checked against `markBBox` in tests.
196
+ */
197
+ export const APP_MARK_VIEWBOX: Record<AppName, string> = {
198
+ projectv: PROJECTV_MARK_VIEWBOX,
199
+ vibot: "227.5 198.5 629 629",
200
+ vhub: "162.5 197.5 629 629",
201
+ vmem: "197.24772201231343 196.33100022273135 629 629",
202
+ verve: "197.5 210.9 629 629",
203
+ velth: "197.5 202.45 629 629",
204
+ };
205
+
206
+ /**
207
+ * Start-aligned viewBox per app, for `Icon*` `align="start"`. Same side and same y as `APP_MARK_VIEWBOX`; x is
208
+ * `markBBox(app)` minX, so the glyph's left ink edge sits flush with the frame's left edge. Hardcoded; checked
209
+ * against `markBBox` in tests.
210
+ */
211
+ export const APP_MARK_VIEWBOX_START: Record<AppName, string> = {
212
+ projectv: "172 181 687 687",
213
+ vibot: "295 198.5 629 629",
214
+ vhub: "186 197.5 629 629",
215
+ vmem: "255.5301217210191 196.33100022273135 629 629",
216
+ verve: "260.4 210.9 629 629",
217
+ velth: "273.9433593555699 202.45 629 629",
218
+ };
219
+
220
+ /** Width over height of a mark's viewBox. Always about 1, because the viewBoxes are square. Kept for API stability; do not use it for sizing. */
221
+ export function markAspect(app: AppName): number {
222
+ const [, , w = 1, h = 1] = APP_MARK_VIEWBOX[app].split(" ").map(Number);
223
+ return w / h;
224
+ }
225
+
226
+ const NS = 'xmlns="http://www.w3.org/2000/svg"';
227
+
228
+ /** Attributes that paint the glyph in `color`. */
229
+ export function markPaintAttrs(app: AppName, color: string): string {
230
+ return APP_MARKS[app].kind === "fill" ? `fill="${color}"` : `fill="none" stroke="${color}" stroke-width="${markStrokeWidth(app)}" stroke-linecap="${markLinecap(app)}" stroke-linejoin="round"`;
231
+ }
232
+
233
+ function pathElements(app: AppName): string {
234
+ return APP_MARKS[app].paths.map((d) => `<path d="${d}"/>`).join("");
235
+ }
236
+
237
+ /** Glyph only on its equalised square viewBox, painted with `color` (default `currentColor`). */
238
+ export function appMarkSvg(app: AppName, color = "currentColor"): string {
239
+ return `<svg ${NS} viewBox="${APP_MARK_VIEWBOX[app]}" ${markPaintAttrs(app, color)}>${pathElements(app)}</svg>\n`;
240
+ }
241
+
242
+ /** Tile with a glyph. `radius` 0 gives a full-bleed square (apple-touch, maskable). */
243
+ export function appTileSvg(app: AppName, tone: "dark" | "light" = "dark", radius = TILE_RADIUS): string {
244
+ const [bg, fg] = tone === "dark" ? ["#000", "#fff"] : ["#fff", "#000"];
245
+ const rx = radius > 0 ? ` rx="${radius}"` : "";
246
+ return `<svg ${NS} viewBox="${MARK_VIEWBOX}"><rect width="1024" height="1024"${rx} fill="${bg}"/><g ${markPaintAttrs(app, fg)}>${pathElements(app)}</g></svg>\n`;
247
+ }
248
+
249
+ /** Android adaptive-icon layer: 432 canvas, glyph scaled into the 288 inner square. */
250
+ export function appAdaptiveSvg(app: AppName, color: string): string {
251
+ const scale = 288 / 1024;
252
+ 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`;
253
+ }
254
+
255
+ /** Chrome extension 128px icon: 96px tile plus 16px transparent padding. */
256
+ export function appExtension128Svg(app: AppName): string {
257
+ const inner = appTileSvg(app).replace(`<svg ${NS} viewBox="${MARK_VIEWBOX}">`, `<svg ${NS} x="16" y="16" width="96" height="96" viewBox="${MARK_VIEWBOX}">`);
258
+ return `<svg ${NS} viewBox="0 0 128 128">${inner.trim()}</svg>\n`;
259
+ }
260
+
261
+ export const APP_MARK_SVG = Object.fromEntries(APP_NAMES.map((app) => [app, appMarkSvg(app)])) as Record<AppName, string>;
262
+ export const APP_TILE_SVG = Object.fromEntries(APP_NAMES.map((app) => [app, appTileSvg(app)])) as Record<AppName, string>;
263
+ export const APP_TILE_LIGHT_SVG = Object.fromEntries(APP_NAMES.map((app) => [app, appTileSvg(app, "light")])) as Record<AppName, string>;
@@ -0,0 +1,22 @@
1
+ import type { AppName } from "./svg";
2
+
3
+ /** Benefits for one app, shown under that app's icon and name. */
4
+ export type PricingPlanAppBenefit = {
5
+ app: Exclude<AppName, "projectv">;
6
+ items: readonly string[];
7
+ };
8
+
9
+ /** Approved per-app copy for V Pro, in display order. */
10
+ export const V_PRO_APP_BENEFITS: readonly PricingPlanAppBenefit[] = [
11
+ {
12
+ app: "vibot",
13
+ items: [
14
+ "Higher AI limits/usage",
15
+ "Smarter AI for chats, documents & sub-agents",
16
+ ],
17
+ },
18
+ { app: "vmem", items: ["Unlimited memories", "Higher AI search & connected-tool limits"] },
19
+ { app: "vhub", items: ["Unlimited scripts", "More connections, secrets & API keys"] },
20
+ { app: "velth", items: ["Unlimited camera scans", "Smarter AI health insights"] },
21
+ { app: "verve", items: ["Page version history", "Higher AI rewrite & summary limits"] },
22
+ ];