@vvedantb/native-ui 0.2.1 → 0.3.12

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 (86) hide show
  1. package/README.md +160 -9
  2. package/package.json +80 -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/PillTabs.tsx +28 -14
  25. package/src/Progress.tsx +67 -0
  26. package/src/SearchInput.tsx +163 -0
  27. package/src/Select.tsx +32 -6
  28. package/src/Separator.tsx +38 -0
  29. package/src/Sheet.tsx +107 -0
  30. package/src/Skeleton.tsx +25 -5
  31. package/src/Spinner.tsx +92 -0
  32. package/src/StatusDot.tsx +66 -0
  33. package/src/Switch.tsx +24 -7
  34. package/src/Tabs.tsx +190 -0
  35. package/src/Textarea.tsx +47 -0
  36. package/src/Toast.tsx +170 -0
  37. package/src/blur.ts +48 -0
  38. package/src/brand/AppIcons.tsx +106 -0
  39. package/src/brand/AppMark.tsx +83 -0
  40. package/src/brand/AppWordmark.tsx +97 -0
  41. package/src/brand/familyApps.ts +24 -0
  42. package/src/brand/index.ts +34 -0
  43. package/src/brand/svg.ts +263 -0
  44. package/src/brand/vProAppBenefits.ts +22 -0
  45. package/src/controlStyles.ts +360 -0
  46. package/src/dialogLayout.ts +20 -0
  47. package/src/entries/ambient.ts +3 -0
  48. package/src/entries/bottom-sheet.ts +2 -0
  49. package/src/entries/brand.ts +2 -0
  50. package/src/entries/button.ts +3 -0
  51. package/src/entries/date-time.ts +3 -0
  52. package/src/entries/floating.ts +24 -0
  53. package/src/entries/layout.ts +7 -0
  54. package/src/entries/marketing.ts +10 -0
  55. package/src/entries/primitives.ts +99 -0
  56. package/src/entries/search-input.ts +4 -0
  57. package/src/entries/tab-bar.ts +10 -0
  58. package/src/entries/theme.ts +15 -0
  59. package/src/entries/toast.ts +6 -0
  60. package/src/examples/ParityGallery.tsx +496 -0
  61. package/src/floating.ts +5 -1
  62. package/src/glassBlur.tsx +95 -0
  63. package/src/gradient.ts +21 -0
  64. package/src/haptics.ts +22 -2
  65. package/src/index.ts +99 -2
  66. package/src/initials.ts +10 -0
  67. package/src/marketing/PricingModal.tsx +410 -0
  68. package/src/marketing/UpgradeToProCard.tsx +103 -0
  69. package/src/marketing/pricing.ts +76 -0
  70. package/src/palette.ts +43 -0
  71. package/src/primitives/input.tsx +9 -2
  72. package/src/primitives/native-only-animated-view.tsx +10 -3
  73. package/src/primitives/overlay.tsx +179 -0
  74. package/src/primitives/select.tsx +25 -12
  75. package/src/primitives/switch.tsx +12 -13
  76. package/src/primitives/use-controllable-state.ts +24 -0
  77. package/src/settings/SettingsField.tsx +11 -2
  78. package/src/settings/SettingsSection.tsx +20 -6
  79. package/src/settings/SettingsToggleRow.tsx +7 -2
  80. package/src/tabBarRoutes.ts +52 -0
  81. package/src/toastChrome.ts +103 -0
  82. package/src/toastStore.ts +86 -0
  83. package/src/tokens.ts +19 -14
  84. package/src/typography.ts +24 -0
  85. package/src/useReduceMotion.ts +24 -0
  86. package/src/useScheme.ts +6 -0
@@ -0,0 +1,47 @@
1
+ import * as React from "react";
2
+ import { Input as PrimitiveInput } from "./primitives/input";
3
+ import { cn } from "./cn";
4
+ import { fieldStyle, fieldTextStyle, typeStyle } from "./controlStyles";
5
+ import { palette } from "./palette";
6
+ import { useLargerText } from "./theme";
7
+ import { useScheme } from "./useScheme";
8
+
9
+ type TextareaProps = React.ComponentPropsWithoutRef<typeof PrimitiveInput> & {
10
+ className?: string;
11
+ };
12
+
13
+ /** Multiline `Input`: same fill, border, radius and type. Grows from 4 lines. */
14
+ const Textarea = React.forwardRef<
15
+ React.ElementRef<typeof PrimitiveInput>,
16
+ TextareaProps
17
+ >(({ className, placeholderTextColor, numberOfLines = 4, style, ...props }, ref) => {
18
+ const largerText = useLargerText();
19
+ const scheme = useScheme();
20
+ return (
21
+ <PrimitiveInput
22
+ ref={ref}
23
+ multiline
24
+ numberOfLines={numberOfLines}
25
+ textAlignVertical="top"
26
+ placeholderTextColor={placeholderTextColor ?? palette.fieldPlaceholder[scheme]}
27
+ className={cn(
28
+ // The web textarea: min-h-20 px-3.5 py-2.5, 16 px type.
29
+ "min-h-20 border px-3.5 py-2.5 text-base font-instrument-sans",
30
+ largerText && "min-h-32 px-4 py-3 text-xl font-instrument-sans-medium",
31
+ className,
32
+ )}
33
+ style={[
34
+ fieldStyle(scheme),
35
+ fieldTextStyle(scheme),
36
+ largerText ? typeStyle("medium") : null,
37
+ style,
38
+ ]}
39
+ {...props}
40
+ />
41
+ );
42
+ });
43
+
44
+ Textarea.displayName = "Textarea";
45
+
46
+ export { Textarea };
47
+ export type { TextareaProps };
package/src/Toast.tsx ADDED
@@ -0,0 +1,170 @@
1
+ import { Fragment, useEffect, useSyncExternalStore } from "react";
2
+ import {
3
+ AccessibilityInfo,
4
+ ActivityIndicator,
5
+ Platform,
6
+ Pressable,
7
+ Text,
8
+ View,
9
+ } from "react-native";
10
+ import { FadeInUp, FadeOutUp, LinearTransition } from "react-native-reanimated";
11
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
12
+ import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens";
13
+ import Svg, { Path } from "react-native-svg";
14
+ import { cn } from "./cn";
15
+ import { sizeStyle, textStyle, typeStyle } from "./controlStyles";
16
+ import { FLOATING_INSET } from "./floating";
17
+ import {
18
+ TOAST_ICON_SIZE,
19
+ TOAST_ICONS,
20
+ toastIconColor,
21
+ toastShadowStyle,
22
+ toastSurfaceStyle,
23
+ } from "./toastChrome";
24
+ import { tokens } from "./tokens";
25
+ import { useLargerText } from "./theme";
26
+ import { useScheme } from "./useScheme";
27
+ import { NativeOnlyAnimatedView } from "./primitives/native-only-animated-view";
28
+ import { getToasts, subscribeToasts, toast, type ToastItem } from "./toastStore";
29
+
30
+ // On iOS the overlay draws above RN `Modal`s, so toasts show over dialogs (as
31
+ // sonner does on the web). On Android a `Modal` still covers them.
32
+ const FullWindowOverlay = Platform.OS === "ios" ? RNFullWindowOverlay : Fragment;
33
+
34
+ const toastShadow = toastShadowStyle(Platform.OS);
35
+
36
+ /**
37
+ * Renders the `toast()` queue at the top of the screen, inside the safe-area
38
+ * insets. Mount once, inside `SafeAreaProvider` and after the navigator.
39
+ */
40
+ export function Toaster({ offset = 8 }: { offset?: number }) {
41
+ const items = useSyncExternalStore(subscribeToasts, getToasts, getToasts);
42
+ const insets = useSafeAreaInsets();
43
+ if (items.length === 0) return null;
44
+ return (
45
+ <FullWindowOverlay>
46
+ <View
47
+ pointerEvents="box-none"
48
+ className="gap-2"
49
+ style={{
50
+ position: "absolute",
51
+ top: insets.top + offset,
52
+ left: insets.left + FLOATING_INSET,
53
+ right: insets.right + FLOATING_INSET,
54
+ zIndex: 50,
55
+ }}
56
+ >
57
+ {items.map((item) => (
58
+ <ToastCard key={item.id} item={item} />
59
+ ))}
60
+ </View>
61
+ </FullWindowOverlay>
62
+ );
63
+ }
64
+
65
+ /** The web filled 24 icons (`toastChrome`), or a spinner while loading. */
66
+ function ToastIcon({ item }: { item: ToastItem }) {
67
+ const scheme = useScheme();
68
+ if (item.variant === "loading") {
69
+ return <ActivityIndicator size="small" color={tokens.color.muted[scheme]} />;
70
+ }
71
+ if (item.variant === "default") return null;
72
+ const { viewBox, d, evenOdd } = TOAST_ICONS[item.variant];
73
+ const rule = evenOdd ? "evenodd" : undefined;
74
+ return (
75
+ <Svg width={TOAST_ICON_SIZE} height={TOAST_ICON_SIZE} viewBox={viewBox}>
76
+ <Path
77
+ d={d}
78
+ fill={toastIconColor(item.variant, scheme)}
79
+ fillRule={rule}
80
+ clipRule={rule}
81
+ />
82
+ </Svg>
83
+ );
84
+ }
85
+
86
+ function ToastCard({ item }: { item: ToastItem }) {
87
+ const largerText = useLargerText();
88
+ const scheme = useScheme();
89
+
90
+ useEffect(() => {
91
+ if (!Number.isFinite(item.duration)) return;
92
+ const timer = setTimeout(() => toast.dismiss(item.id), item.duration);
93
+ return () => clearTimeout(timer);
94
+ }, [item]);
95
+
96
+ useEffect(() => {
97
+ AccessibilityInfo.announceForAccessibility(
98
+ item.description ? `${item.title}. ${item.description}` : item.title,
99
+ );
100
+ }, [item.title, item.description]);
101
+
102
+ return (
103
+ <NativeOnlyAnimatedView
104
+ entering={FadeInUp.duration(200)}
105
+ exiting={FadeOutUp.duration(160)}
106
+ layout={LinearTransition}
107
+ >
108
+ {/* The web toast: flat overlay fill, radius 16, ring and a light shadow. */}
109
+ <View style={[toastSurfaceStyle(scheme), toastShadow]}>
110
+ <Pressable
111
+ testID={`toast-${item.id}`}
112
+ // With an action, the text and the action button stay separate
113
+ // elements for VoiceOver and TalkBack instead of one grouped button.
114
+ accessible={!item.action}
115
+ accessibilityRole="button"
116
+ accessibilityHint="Closes the message"
117
+ onPress={() => toast.dismiss(item.id)}
118
+ className="flex-row items-center gap-3 px-4 py-3"
119
+ >
120
+ <ToastIcon item={item} />
121
+ <View className="min-w-0 flex-1">
122
+ {/* Web `text-sm font-medium leading-snug` (14 / 19.25, 500); `text-lg` with larger text. */}
123
+ <Text
124
+ className={cn(
125
+ "font-instrument-sans-medium",
126
+ largerText ? "text-lg" : "text-sm",
127
+ )}
128
+ style={[
129
+ textStyle("foreground", scheme),
130
+ typeStyle("medium"),
131
+ largerText ? sizeStyle("text-lg") : sizeStyle("text-sm", "snug"),
132
+ ]}
133
+ >
134
+ {item.title}
135
+ </Text>
136
+ {item.description ? (
137
+ <Text
138
+ className={cn(
139
+ "font-instrument-sans",
140
+ largerText ? "text-base" : "text-sm",
141
+ )}
142
+ style={textStyle("muted", scheme)}
143
+ >
144
+ {item.description}
145
+ </Text>
146
+ ) : null}
147
+ </View>
148
+ {item.action ? (
149
+ <Pressable
150
+ accessibilityRole="button"
151
+ hitSlop={8}
152
+ onPress={() => {
153
+ item.action?.onPress();
154
+ toast.dismiss(item.id);
155
+ }}
156
+ className="rounded-full bg-secondary px-3 py-1.5 active:opacity-70"
157
+ >
158
+ <Text
159
+ className="font-instrument-sans-semibold text-sm"
160
+ style={[textStyle("foreground", scheme), typeStyle("semibold")]}
161
+ >
162
+ {item.action.label}
163
+ </Text>
164
+ </Pressable>
165
+ ) : null}
166
+ </Pressable>
167
+ </View>
168
+ </NativeOnlyAnimatedView>
169
+ );
170
+ }
package/src/blur.ts ADDED
@@ -0,0 +1,48 @@
1
+ import type * as ExpoBlur from "expo-blur";
2
+
3
+ export type ExpoBlurModule = typeof ExpoBlur;
4
+
5
+ let expoBlur: ExpoBlurModule | null | undefined;
6
+
7
+ /**
8
+ * `expo-blur`, or `null` when the optional peer is not installed. Metro
9
+ * bundles a `require` placed directly in a `try` block as an optional
10
+ * dependency, so a missing peer does not break the bundle.
11
+ */
12
+ export function loadBlur(): ExpoBlurModule | null {
13
+ if (expoBlur === undefined) {
14
+ try {
15
+ expoBlur = require("expo-blur") as ExpoBlurModule;
16
+ } catch {
17
+ expoBlur = null;
18
+ }
19
+ }
20
+ return expoBlur;
21
+ }
22
+
23
+ export type GlassMode = "blur" | "backdrop" | "solid";
24
+
25
+ /**
26
+ * How a floating surface draws its glass: a native `BlurView` on iOS and
27
+ * Android, a CSS backdrop blur on web, or the solid fill when blur is off,
28
+ * Reduce Transparency is on or `expo-blur` is missing.
29
+ */
30
+ export function glassMode({
31
+ os,
32
+ blur,
33
+ reduceTransparency,
34
+ hasBlurView,
35
+ }: {
36
+ os: string;
37
+ blur: boolean;
38
+ reduceTransparency: boolean;
39
+ hasBlurView: boolean;
40
+ }): GlassMode {
41
+ if (!blur || reduceTransparency) {
42
+ return "solid";
43
+ }
44
+ if (os === "web") {
45
+ return "backdrop";
46
+ }
47
+ return (os === "ios" || os === "android") && hasBlurView ? "blur" : "solid";
48
+ }
@@ -0,0 +1,106 @@
1
+ import type { StyleProp, ViewStyle } from "react-native";
2
+ import Svg, { G, Path } from "react-native-svg";
3
+ import { tokens } from "../tokens";
4
+ import { useScheme } from "../useScheme";
5
+ import {
6
+ APP_MARKS,
7
+ APP_MARK_VIEWBOX,
8
+ APP_MARK_VIEWBOX_START,
9
+ APP_NAMES,
10
+ markLinecap,
11
+ markStrokeWidth,
12
+ type AppName,
13
+ } from "./svg";
14
+
15
+ export type AppIconProps = {
16
+ /** Rendered width and height in points (the glyph is square). Default 24. */
17
+ size?: number;
18
+ /** Glyph colour, for fill and stroke glyphs. Default the scheme's `tokens.color.foreground`. */
19
+ color?: string;
20
+ /** Stroke width in rendered points, for stroke glyphs. Default: the glyph's own weight. Fill glyphs ignore it. */
21
+ stroke?: number;
22
+ /** Accessible name. Without it the icon is hidden from assistive tech. */
23
+ title?: string;
24
+ /** `"center"` (default) centres the glyph in its square. `"start"` left-aligns its ink, for vertical lists. */
25
+ align?: "center" | "start";
26
+ testID?: string;
27
+ style?: StyleProp<ViewStyle>;
28
+ };
29
+
30
+ export type AppIconComponent = ((props: AppIconProps) => React.JSX.Element) & {
31
+ displayName?: string;
32
+ };
33
+
34
+ function viewBoxSide(app: AppName): number {
35
+ return Number(APP_MARK_VIEWBOX[app].split(" ")[3]);
36
+ }
37
+
38
+ function createAppIcon(app: AppName, displayName: string): AppIconComponent {
39
+ function Icon({
40
+ size = 24,
41
+ color,
42
+ stroke,
43
+ title,
44
+ align = "center",
45
+ testID,
46
+ style,
47
+ }: AppIconProps) {
48
+ const scheme = useScheme();
49
+ const fill = color ?? tokens.color.foreground[scheme];
50
+ const { kind, paths } = APP_MARKS[app];
51
+ const strokeWidth =
52
+ stroke === undefined
53
+ ? markStrokeWidth(app)
54
+ : stroke * (viewBoxSide(app) / size);
55
+ const glyphProps =
56
+ kind === "fill"
57
+ ? { fill }
58
+ : {
59
+ fill: "none",
60
+ stroke: fill,
61
+ strokeWidth,
62
+ strokeLinecap: markLinecap(app),
63
+ strokeLinejoin: "round" as const,
64
+ };
65
+ return (
66
+ <Svg
67
+ width={size}
68
+ height={size}
69
+ viewBox={
70
+ align === "start" ? APP_MARK_VIEWBOX_START[app] : APP_MARK_VIEWBOX[app]
71
+ }
72
+ preserveAspectRatio="xMidYMid meet"
73
+ accessible={Boolean(title)}
74
+ accessibilityRole={title ? "image" : undefined}
75
+ accessibilityLabel={title}
76
+ accessibilityElementsHidden={!title}
77
+ importantForAccessibility={title ? "auto" : "no-hide-descendants"}
78
+ testID={testID}
79
+ style={style}
80
+ >
81
+ <G {...glyphProps}>
82
+ {paths.map((d) => (
83
+ <Path key={d} d={d} />
84
+ ))}
85
+ </G>
86
+ </Svg>
87
+ );
88
+ }
89
+ Icon.displayName = displayName;
90
+ return Icon;
91
+ }
92
+
93
+ const iconName = (app: AppName) =>
94
+ `Icon${app === "projectv" ? "ProjectV" : app[0]!.toUpperCase() + app.slice(1)}`;
95
+
96
+ /** Plain app glyph components by app name, for dynamic lookup. Same paths and viewBoxes as the web `APP_ICONS`. */
97
+ export const APP_ICONS = Object.fromEntries(
98
+ APP_NAMES.map((app) => [app, createAppIcon(app, iconName(app))]),
99
+ ) as Record<AppName, AppIconComponent>;
100
+
101
+ export const IconVibot = APP_ICONS.vibot;
102
+ export const IconVhub = APP_ICONS.vhub;
103
+ export const IconVmem = APP_ICONS.vmem;
104
+ export const IconVerve = APP_ICONS.verve;
105
+ export const IconVelth = APP_ICONS.velth;
106
+ export const IconProjectV = APP_ICONS.projectv;
@@ -0,0 +1,83 @@
1
+ import type { StyleProp, ViewStyle } from "react-native";
2
+ import Svg, { G, Path, Rect } from "react-native-svg";
3
+ import { APP_ICONS } from "./AppIcons";
4
+ import {
5
+ APP_MARKS,
6
+ MARK_VIEWBOX,
7
+ TILE_RADIUS,
8
+ markLinecap,
9
+ markStrokeWidth,
10
+ type AppMarkVariant,
11
+ type AppName,
12
+ } from "./svg";
13
+
14
+ export type AppMarkProps = {
15
+ /** Which family app's mark to draw. */
16
+ app: AppName;
17
+ /** `"mark"` (default): the plain `APP_ICONS` glyph. `"tile"`: white glyph on a black rounded tile. `"tile-light"`: black glyph on a white tile. */
18
+ variant?: AppMarkVariant;
19
+ /** Width and height in points. Default 24. */
20
+ size?: number;
21
+ /** Accessible name. Without it the mark is hidden from assistive tech. */
22
+ title?: string;
23
+ testID?: string;
24
+ style?: StyleProp<ViewStyle>;
25
+ };
26
+
27
+ const TILE_TONES = {
28
+ tile: { bg: "#000", fg: "#fff" },
29
+ "tile-light": { bg: "#fff", fg: "#000" },
30
+ } as const;
31
+
32
+ /**
33
+ * One of the six family app marks, as on the web `AppMark`. Tiles share the
34
+ * 1024 grid with the plain mark, so a bare glyph matches the tile glyph.
35
+ * Prefer `APP_ICONS[app]` for plain marks; tiles are for app icons only.
36
+ */
37
+ export function AppMark({
38
+ app,
39
+ variant = "mark",
40
+ size = 24,
41
+ title,
42
+ testID,
43
+ style,
44
+ }: AppMarkProps) {
45
+ if (variant === "mark") {
46
+ const Icon = APP_ICONS[app];
47
+ return <Icon size={size} title={title} testID={testID} style={style} />;
48
+ }
49
+ const { kind, paths } = APP_MARKS[app];
50
+ const tone = TILE_TONES[variant];
51
+ const glyphProps =
52
+ kind === "fill"
53
+ ? { fill: tone.fg }
54
+ : {
55
+ fill: "none",
56
+ stroke: tone.fg,
57
+ strokeWidth: markStrokeWidth(app),
58
+ strokeLinecap: markLinecap(app),
59
+ strokeLinejoin: "round" as const,
60
+ };
61
+ return (
62
+ <Svg
63
+ width={size}
64
+ height={size}
65
+ viewBox={MARK_VIEWBOX}
66
+ preserveAspectRatio="xMidYMid meet"
67
+ accessible={Boolean(title)}
68
+ accessibilityRole={title ? "image" : undefined}
69
+ accessibilityLabel={title}
70
+ accessibilityElementsHidden={!title}
71
+ importantForAccessibility={title ? "auto" : "no-hide-descendants"}
72
+ testID={testID}
73
+ style={style}
74
+ >
75
+ <Rect width={1024} height={1024} rx={TILE_RADIUS} fill={tone.bg} />
76
+ <G {...glyphProps}>
77
+ {paths.map((d) => (
78
+ <Path key={d} d={d} />
79
+ ))}
80
+ </G>
81
+ </Svg>
82
+ );
83
+ }
@@ -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";