@vvedantb/native-ui 0.12.1 → 0.17.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.
Files changed (113) hide show
  1. package/README.md +82 -19
  2. package/package.json +36 -3
  3. package/src/BottomSheetContainer.tsx +2 -2
  4. package/src/BulkActionBar.tsx +240 -105
  5. package/src/Button.tsx +11 -7
  6. package/src/ConfirmDialog.tsx +25 -35
  7. package/src/Dialog.tsx +47 -35
  8. package/src/DividedList.tsx +11 -7
  9. package/src/DropdownMenu.tsx +22 -8
  10. package/src/FloatingSurface.tsx +5 -2
  11. package/src/FloatingTabBar.tsx +13 -22
  12. package/src/HeaderIconButton.tsx +4 -3
  13. package/src/Input.tsx +2 -2
  14. package/src/LoadingState.tsx +132 -0
  15. package/src/PageTitle.tsx +9 -2
  16. package/src/Pager.tsx +62 -0
  17. package/src/PasswordInput.tsx +1 -1
  18. package/src/PillTabs.tsx +144 -34
  19. package/src/Progress.tsx +9 -7
  20. package/src/Select.tsx +4 -4
  21. package/src/Sheet.tsx +29 -22
  22. package/src/SheetRow.tsx +27 -9
  23. package/src/Skeleton.tsx +91 -9
  24. package/src/SurfaceRow.tsx +111 -0
  25. package/src/SwipeableRow.tsx +43 -0
  26. package/src/Switch.tsx +6 -4
  27. package/src/Tabs.tsx +68 -13
  28. package/src/Toast.tsx +107 -65
  29. package/src/ai/activity-step-detail.tsx +1 -3
  30. package/src/ai/activity-step-label.ts +981 -0
  31. package/src/ai/activity-step-types.ts +107 -0
  32. package/src/ai/activity-tasks-utils.ts +199 -0
  33. package/src/ai/activity-timeline.ts +45 -0
  34. package/src/ai/attachments.tsx +3 -1
  35. package/src/ai/card-button.tsx +49 -0
  36. package/src/ai/chat-text.ts +13 -7
  37. package/src/ai/chat-thread.tsx +34 -27
  38. package/src/ai/choice-question.tsx +154 -35
  39. package/src/ai/code-block.tsx +189 -49
  40. package/src/ai/collapsible.tsx +19 -13
  41. package/src/ai/conversation.tsx +390 -57
  42. package/src/ai/icon-button.tsx +41 -6
  43. package/src/ai/index.ts +52 -1
  44. package/src/ai/message-action-sheet.tsx +168 -0
  45. package/src/ai/message-quote.tsx +98 -0
  46. package/src/ai/message.tsx +226 -31
  47. package/src/ai/prompt-input-menu.tsx +197 -0
  48. package/src/ai/prompt-input.tsx +308 -73
  49. package/src/ai/reply-quote-chip.tsx +90 -0
  50. package/src/ai/shimmer.tsx +4 -6
  51. package/src/ai/subagent-cards.tsx +209 -0
  52. package/src/ai/tool-approval-card.tsx +162 -0
  53. package/src/ai/trace.tsx +54 -17
  54. package/src/ai/unified-diff.tsx +182 -0
  55. package/src/auth/GoogleSignInButton.tsx +6 -3
  56. package/src/auth/SignInScreen.tsx +11 -2
  57. package/src/brand/ProviderIcon.tsx +136 -0
  58. package/src/brand/index.ts +2 -0
  59. package/src/configureTheme.ts +39 -11
  60. package/src/controlStyles.ts +46 -30
  61. package/src/entries/gesture-handler.ts +4 -0
  62. package/src/entries/keyboard-controller.ts +4 -0
  63. package/src/entries/markdown.ts +1 -1
  64. package/src/entries/primitives.ts +24 -3
  65. package/src/entries/theme.ts +2 -1
  66. package/src/entries/video.ts +4 -0
  67. package/src/entries/webview.ts +6 -0
  68. package/src/floating.ts +25 -4
  69. package/src/haptics.ts +30 -4
  70. package/src/highlight.ts +302 -1
  71. package/src/index.ts +27 -5
  72. package/src/loadingGrid.ts +75 -0
  73. package/src/markdown/Markdown.tsx +234 -32
  74. package/src/markdown/MarkdownTable.tsx +188 -0
  75. package/src/markdown/tableText.ts +58 -0
  76. package/src/marketing/UpgradeToProCard.tsx +4 -3
  77. package/src/media/ImageGallery.tsx +224 -0
  78. package/src/media/MediaCard.tsx +100 -0
  79. package/src/media/imageGrid.ts +51 -0
  80. package/src/motion.ts +33 -0
  81. package/src/pagerSwipe.ts +102 -0
  82. package/src/palette.ts +8 -1
  83. package/src/primitives/button.tsx +4 -1
  84. package/src/primitives/keyboard-controller.tsx +106 -0
  85. package/src/primitives/keyboard-parts.tsx +104 -0
  86. package/src/primitives/overlay.tsx +133 -45
  87. package/src/primitives/pager-gesture.tsx +131 -0
  88. package/src/primitives/sheet-drag.tsx +305 -0
  89. package/src/primitives/sheet-gesture.tsx +181 -0
  90. package/src/primitives/swipe-row-gesture.tsx +290 -0
  91. package/src/primitives/toast-drag-gesture.tsx +92 -0
  92. package/src/primitives/web-click-guard.ts +50 -0
  93. package/src/relativeTime.ts +94 -0
  94. package/src/router/RouterStackPageHeader.tsx +2 -2
  95. package/src/settings/SettingsEmptyState.tsx +20 -10
  96. package/src/settings/SettingsField.tsx +11 -5
  97. package/src/settings/SettingsSection.tsx +8 -5
  98. package/src/settings/SettingsToggleRow.tsx +55 -22
  99. package/src/settings/relaxedSizeStyle.ts +11 -0
  100. package/src/sheetDrag.ts +67 -0
  101. package/src/swipeRow.ts +169 -0
  102. package/src/tabBarRoutes.ts +18 -0
  103. package/src/tabIndicator.ts +71 -0
  104. package/src/tailwind-preset.cjs +13 -6
  105. package/src/tailwind-preset.d.cts +5 -0
  106. package/src/theme.tsx +6 -2
  107. package/src/toastDrag.ts +58 -0
  108. package/src/typography.ts +20 -3
  109. package/src/useScheme.ts +10 -1
  110. package/src/video/InlineVideo.tsx +91 -0
  111. package/src/webview/HtmlView.tsx +91 -0
  112. package/src/webview/HtmlView.web.tsx +21 -0
  113. package/src/webview/htmlView.ts +45 -0
@@ -0,0 +1,182 @@
1
+ import { Pressable, ScrollView, Text, View, type TextStyle } from "react-native";
2
+ import { sizeStyle, textStyle } from "../controlStyles";
3
+ import { palette, withAlpha } from "../palette";
4
+ import { tokens } from "../tokens";
5
+ import { useLargerText } from "../theme";
6
+ import { useScheme } from "../useScheme";
7
+ import { MONO_FONT, chatTextSize } from "./chat-text";
8
+
9
+ /** One row of a unified diff: a hunk separator, or a line with its old and new line numbers. */
10
+ export type UnifiedDiffLine =
11
+ | { readonly kind: "hunk"; readonly text: string }
12
+ | {
13
+ readonly kind: "added" | "removed" | "context";
14
+ /** The line's content, without its `+`/`-`/space marker. */
15
+ readonly text: string;
16
+ /** Null for an added line. */
17
+ readonly oldLine: number | null;
18
+ /** Null for a removed line. */
19
+ readonly newLine: number | null;
20
+ };
21
+
22
+ /**
23
+ * The web Code tab type (`--diffs-font-size: 14px`, `--diffs-line-height: 22px`),
24
+ * the gutter padding and the width of one gutter digit.
25
+ */
26
+ export const UNIFIED_DIFF = { fontSize: 14, lineHeight: 22, gutterPadding: 8, digitWidth: 8.5 } as const;
27
+
28
+ /** Larger text steps the code up as `chatTextSize("small")` does (`text-lg`), keeping the 22/14 leading. */
29
+ const LARGER_FONT_SIZE = 18;
30
+
31
+ /** Web `bg-emerald-500/10` and `text-emerald-700` (dark `text-emerald-300`): edit and diff added lines. */
32
+ export const EMERALD = { fill: "#10b981", light: "#047857", dark: "#6ee7b7" } as const;
33
+
34
+ /** Web `text-emerald-600` (dark `text-emerald-400`), the `+n` of a diff stat. */
35
+ const ADDED_TEXT = { light: "#059669", dark: "#34d399" } as const;
36
+
37
+ const SIGN = { added: "+", removed: "-", context: " " } as const;
38
+
39
+ const TABULAR: TextStyle = { fontVariant: ["tabular-nums"] };
40
+
41
+ export type UnifiedDiffProps = {
42
+ lines: ReadonlyArray<UnifiedDiffLine>;
43
+ /**
44
+ * Makes each code row (not a hunk header) a button: a tap calls this with
45
+ * the row's index in `lines`. The code is then not selectable, so a tap
46
+ * always lands.
47
+ */
48
+ onLinePress?: (index: number) => void;
49
+ /** What a row's button does, for a screen reader (`accessibilityHint`). */
50
+ linePressHint?: string;
51
+ /** Indexes in `lines` of the rows to mark, such as those with a comment: their gutter fills. */
52
+ markedLines?: ReadonlySet<number>;
53
+ className?: string;
54
+ };
55
+
56
+ /**
57
+ * One file's diff in one column, as the web Code tab draws it in unified view
58
+ * (`@pierre/diffs`): a line number gutter (the old number on a removed line,
59
+ * the new one otherwise), the `+`/`-` marker, and the line in mono 14 / 22.
60
+ * Added lines tint green and removed lines red, each at 10%; a hunk header is
61
+ * a muted row on the secondary surface. Long lines do not wrap: the whole
62
+ * diff scrolls sideways, and every row spans the widest line. With
63
+ * `onLinePress` a code row is a button (`Line N`); `markedLines` fills the
64
+ * gutter of the rows it names at 12% of the primary colour.
65
+ */
66
+ export function UnifiedDiff({ lines, onLinePress, linePressHint, markedLines, className }: UnifiedDiffProps) {
67
+ const scheme = useScheme();
68
+ const largerText = useLargerText();
69
+ const fontSize = largerText ? LARGER_FONT_SIZE : UNIFIED_DIFF.fontSize;
70
+ const widest = lines.reduce(
71
+ (max, line) => (line.kind === "hunk" ? max : Math.max(max, line.oldLine ?? 0, line.newLine ?? 0)),
72
+ 0,
73
+ );
74
+ const scale = fontSize / UNIFIED_DIFF.fontSize;
75
+ const gutter = String(widest).length * UNIFIED_DIFF.digitWidth * scale + UNIFIED_DIFF.gutterPadding * 2;
76
+ const mono = { fontFamily: MONO_FONT, fontSize, lineHeight: UNIFIED_DIFF.lineHeight * scale };
77
+ return (
78
+ <ScrollView horizontal className={className} contentContainerStyle={{ minWidth: "100%" }}>
79
+ <View style={{ flexGrow: 1 }}>
80
+ {lines.map((line, index) => {
81
+ if (line.kind === "hunk") {
82
+ return (
83
+ <Text
84
+ key={index}
85
+ numberOfLines={1}
86
+ style={[
87
+ mono,
88
+ textStyle("muted", scheme),
89
+ { backgroundColor: tokens.color.surfaceSecondary[scheme], paddingHorizontal: UNIFIED_DIFF.gutterPadding },
90
+ ]}
91
+ >
92
+ {line.text}
93
+ </Text>
94
+ );
95
+ }
96
+ const tint =
97
+ line.kind === "added"
98
+ ? withAlpha(EMERALD.fill, 0.1)
99
+ : line.kind === "removed"
100
+ ? withAlpha(palette.danger[scheme], 0.1)
101
+ : undefined;
102
+ const number = line.kind === "removed" ? line.oldLine : line.newLine;
103
+ const cells = [
104
+ <Text
105
+ key="gutter"
106
+ style={[
107
+ mono,
108
+ textStyle(markedLines?.has(index) ? "foreground" : "muted", scheme),
109
+ TABULAR,
110
+ { width: gutter, paddingHorizontal: UNIFIED_DIFF.gutterPadding, textAlign: "right" },
111
+ markedLines?.has(index) ? { backgroundColor: withAlpha(palette.primary[scheme], 0.12) } : null,
112
+ ]}
113
+ >
114
+ {number ?? ""}
115
+ </Text>,
116
+ <Text
117
+ key="code"
118
+ selectable={onLinePress === undefined}
119
+ style={[mono, textStyle("foreground", scheme), { paddingRight: UNIFIED_DIFF.gutterPadding * 2 }]}
120
+ >
121
+ {`${SIGN[line.kind]} ${line.text}`}
122
+ </Text>,
123
+ ];
124
+ if (onLinePress === undefined) {
125
+ return (
126
+ <View key={index} className="flex-row" style={{ backgroundColor: tint }}>
127
+ {cells}
128
+ </View>
129
+ );
130
+ }
131
+ return (
132
+ <Pressable
133
+ key={index}
134
+ accessibilityRole="button"
135
+ accessibilityLabel={`Line ${number ?? ""}`}
136
+ accessibilityHint={linePressHint}
137
+ // No slop: rows tile the diff edge to edge, so any slop would take
138
+ // the next line's taps. Each row is a full-width 22 high target,
139
+ // as dense as the code it holds.
140
+ hitSlop={0}
141
+ onPress={() => onLinePress(index)}
142
+ className="flex-row active:opacity-60"
143
+ style={{ backgroundColor: tint }}
144
+ >
145
+ {cells}
146
+ </Pressable>
147
+ );
148
+ })}
149
+ </View>
150
+ </ScrollView>
151
+ );
152
+ }
153
+
154
+ export type DiffStatProps = {
155
+ additions: number;
156
+ deletions: number;
157
+ /** Drop a side that is zero, so a pure addition reads `+2`, not `+2 −0`. */
158
+ hideZero?: boolean;
159
+ className?: string;
160
+ };
161
+
162
+ /** The web `PrDiffStat`: `+n` in emerald and `−m` in the destructive colour, tabular mono. */
163
+ export function DiffStat({ additions, deletions, hideZero = false, className }: DiffStatProps) {
164
+ const scheme = useScheme();
165
+ const largerText = useLargerText();
166
+ const stat = [sizeStyle(chatTextSize("small", largerText)), TABULAR, { fontFamily: MONO_FONT }];
167
+ return (
168
+ <View
169
+ accessible
170
+ accessibilityLabel={`${additions} additions, ${deletions} deletions`}
171
+ className={className}
172
+ style={{ flexDirection: "row", flexShrink: 0, gap: 4 }}
173
+ >
174
+ {hideZero && additions === 0 ? null : (
175
+ <Text style={[stat, { color: ADDED_TEXT[scheme] }]}>{`+${additions.toLocaleString()}`}</Text>
176
+ )}
177
+ {hideZero && deletions === 0 ? null : (
178
+ <Text style={[stat, { color: palette.danger[scheme] }]}>{`−${deletions.toLocaleString()}`}</Text>
179
+ )}
180
+ </View>
181
+ );
182
+ }
@@ -3,7 +3,7 @@ import { Platform } from "react-native";
3
3
  import { useSSO } from "@clerk/clerk-expo";
4
4
  import * as AuthSession from "expo-auth-session";
5
5
  import * as WebBrowser from "expo-web-browser";
6
- import { useRouter, type Href, type Router } from "expo-router";
6
+ import { useRouter, type Href } from "expo-router";
7
7
  import { GoogleMark } from "../brand/GoogleMark";
8
8
  import { Button, type ButtonProps } from "../Button";
9
9
  import { toast } from "../toastStore";
@@ -17,8 +17,11 @@ export const GOOGLE_ERROR_MESSAGE = "Could not continue with Google";
17
17
  export type AuthNavigationOptions = {
18
18
  /** Where to go after a session starts. Default `"/"`. */
19
19
  afterAuthHref?: Href;
20
- /** Passed to `router.replace`, such as `{ withAnchor: true }`. */
21
- replaceOptions?: Parameters<Router["replace"]>[1];
20
+ /**
21
+ * Passed to `router.replace`, such as `{ withAnchor: true }`. Typed from
22
+ * `useRouter`, as expo-router 57 exports no `Router` type.
23
+ */
24
+ replaceOptions?: Parameters<ReturnType<typeof useRouter>["replace"]>[1];
22
25
  /** Called after a session starts, in place of `router.replace`. */
23
26
  onSuccess?: () => void;
24
27
  };
@@ -1,5 +1,5 @@
1
- import { useState, type ReactNode } from "react";
2
- import { Text, View } from "react-native";
1
+ import { useRef, useState, type ReactNode } from "react";
2
+ import { Text, View, type TextInput } from "react-native";
3
3
  import { useSignIn, useSignUp } from "@clerk/clerk-expo";
4
4
  import { useRouter, type Href } from "expo-router";
5
5
  import { Button } from "../Button";
@@ -203,6 +203,9 @@ export function SignInScreen({
203
203
  const secondFactor = useSecondFactor({ stageTimeoutMs });
204
204
  const [error, setError] = useState("");
205
205
  const [submitting, setSubmitting] = useState(false);
206
+ // The field each "next" key moves to.
207
+ const passwordField = useRef<TextInput>(null);
208
+ const confirmField = useRef<TextInput>(null);
206
209
  const signUp = mode === "sign-up";
207
210
  const id = (name: string) => `${testIDPrefix ?? mode}-${name}`;
208
211
 
@@ -481,12 +484,15 @@ export function SignInScreen({
481
484
  textContentType="newPassword"
482
485
  autoFocus
483
486
  returnKeyType="next"
487
+ submitBehavior="submit"
488
+ onSubmitEditing={() => confirmField.current?.focus()}
484
489
  value={newPassword}
485
490
  placeholder={AUTH_MESSAGES.newPasswordLabel}
486
491
  onChangeText={setNewPassword}
487
492
  />
488
493
  <PasswordInput
489
494
  testID={id("confirm-password")}
495
+ ref={confirmField}
490
496
  accessibilityLabel={AUTH_MESSAGES.confirmPasswordLabel}
491
497
  autoCapitalize="none"
492
498
  autoComplete="new-password"
@@ -661,12 +667,15 @@ export function SignInScreen({
661
667
  textContentType="emailAddress"
662
668
  keyboardType="email-address"
663
669
  returnKeyType="next"
670
+ submitBehavior="submit"
671
+ onSubmitEditing={() => passwordField.current?.focus()}
664
672
  value={email}
665
673
  placeholder="Email"
666
674
  onChangeText={setEmail}
667
675
  />
668
676
  <PasswordInput
669
677
  testID={id("password")}
678
+ ref={passwordField}
670
679
  accessibilityLabel="Password"
671
680
  autoCapitalize="none"
672
681
  autoComplete={signUp ? "new-password" : "password"}
@@ -0,0 +1,136 @@
1
+ import { Platform, type ViewProps } from "react-native";
2
+ import Svg, { Path, Rect } from "react-native-svg";
3
+ import { svgA11yProps } from "../svgA11y";
4
+ import { useScheme } from "../useScheme";
5
+
6
+ /** A fill that follows the colour scheme, as the web `text-black dark:text-white`. */
7
+ type SchemeFill = string | { light: string; dark: string };
8
+
9
+ type ProviderMarkLayer =
10
+ | { d: string; fill: SchemeFill; evenOdd?: boolean }
11
+ /** A full-bleed square behind the glyph (the OpenCode tile). */
12
+ | { square: number; fill: SchemeFill };
13
+
14
+ export type ProviderMark = { viewBox: string; layers: readonly ProviderMarkLayer[] };
15
+
16
+ /** Providers with a mark. Any other provider draws the Claude mark, as the web. */
17
+ export type ProviderIconProvider = "claude" | "codex" | "opencode" | "cursor";
18
+
19
+ /**
20
+ * The provider marks of the web `ProviderIcon` (`@vvedantb/ui`
21
+ * `ai-elements/provider-icon.tsx`): the same path data, viewBoxes and fills.
22
+ * A test checks them against the web source.
23
+ */
24
+ export const PROVIDER_MARKS: Record<ProviderIconProvider, ProviderMark> = {
25
+ claude: {
26
+ viewBox: "0 0 24 24",
27
+ layers: [
28
+ {
29
+ fill: "#D97757",
30
+ d: "m4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z",
31
+ },
32
+ ],
33
+ },
34
+ codex: {
35
+ viewBox: "0 0 256 260",
36
+ layers: [
37
+ {
38
+ fill: { light: "#000", dark: "#fff" },
39
+ d: "M239.184 106.203a64.716 64.716 0 0 0-5.576-53.103C219.452 28.459 191 15.784 163.213 21.74A65.586 65.586 0 0 0 52.096 45.22a64.716 64.716 0 0 0-43.23 31.36c-14.31 24.602-11.061 55.634 8.033 76.74a64.665 64.665 0 0 0 5.525 53.102c14.174 24.65 42.644 37.324 70.446 31.36a64.72 64.72 0 0 0 48.754 21.744c28.481.025 53.714-18.361 62.414-45.481a64.767 64.767 0 0 0 43.229-31.36c14.137-24.558 10.875-55.423-8.083-76.483Zm-97.56 136.338a48.397 48.397 0 0 1-31.105-11.255l1.535-.87 51.67-29.825a8.595 8.595 0 0 0 4.247-7.367v-72.85l21.845 12.636c.218.111.37.32.409.563v60.367c-.056 26.818-21.783 48.545-48.601 48.601Zm-104.466-44.61a48.345 48.345 0 0 1-5.781-32.589l1.534.921 51.722 29.826a8.339 8.339 0 0 0 8.441 0l63.181-36.425v25.221a.87.87 0 0 1-.358.665l-52.335 30.184c-23.257 13.398-52.97 5.431-66.404-17.803ZM23.549 85.38a48.499 48.499 0 0 1 25.58-21.333v61.39a8.288 8.288 0 0 0 4.195 7.316l62.874 36.272-21.845 12.636a.819.819 0 0 1-.767 0L41.353 151.53c-23.211-13.454-31.171-43.144-17.804-66.405v.256Zm179.466 41.695-63.08-36.63L161.73 77.86a.819.819 0 0 1 .768 0l52.233 30.184a48.6 48.6 0 0 1-7.316 87.635v-61.391a8.544 8.544 0 0 0-4.4-7.213Zm21.742-32.69-1.535-.922-51.619-30.081a8.39 8.39 0 0 0-8.492 0L99.98 99.808V74.587a.716.716 0 0 1 .307-.665l52.233-30.133a48.652 48.652 0 0 1 72.236 50.391v.205ZM88.061 139.097l-21.845-12.585a.87.87 0 0 1-.41-.614V65.685a48.652 48.652 0 0 1 79.757-37.346l-1.535.87-51.67 29.825a8.595 8.595 0 0 0-4.246 7.367l-.051 72.697Zm11.868-25.58 28.138-16.217 28.188 16.218v32.434l-28.086 16.218-28.188-16.218-.052-32.434Z",
40
+ },
41
+ ],
42
+ },
43
+ opencode: {
44
+ viewBox: "0 0 512 512",
45
+ layers: [
46
+ { square: 512, fill: "#FDFCFC" },
47
+ { d: "M320 224V352H192V224H320Z", fill: "#E6E5E6" },
48
+ { d: "M384 416H128V96H384V416ZM320 160H192V352H320V160Z", fill: "#17181C", evenOdd: true },
49
+ ],
50
+ },
51
+ cursor: {
52
+ viewBox: "0 0 466.73 532.09",
53
+ layers: [
54
+ {
55
+ fill: { light: "#26251E", dark: "#EDECEC" },
56
+ d: "M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z",
57
+ },
58
+ ],
59
+ },
60
+ };
61
+
62
+ /** The mark for `provider`, the Claude mark for an unknown one (web `ProviderIcon` default). */
63
+ export function providerMark(provider: string): ProviderMark {
64
+ switch (provider) {
65
+ case "codex":
66
+ case "opencode":
67
+ case "cursor":
68
+ return PROVIDER_MARKS[provider];
69
+ default:
70
+ return PROVIDER_MARKS.claude;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * The provider's name in a model picker, as the web `getProviderLabel`
76
+ * (`ai-elements/model-picker-types.ts`): "Claude", "GPT" for Codex,
77
+ * "Opencode", "Cursor", and any other provider as given.
78
+ */
79
+ export function providerLabel(provider: string): string {
80
+ switch (provider) {
81
+ case "claude":
82
+ return "Claude";
83
+ case "codex":
84
+ return "GPT";
85
+ case "opencode":
86
+ return "Opencode";
87
+ case "cursor":
88
+ return "Cursor";
89
+ default:
90
+ return provider;
91
+ }
92
+ }
93
+
94
+ export type ProviderIconProps = {
95
+ /** "claude", "codex", "opencode" or "cursor". Any other value draws the Claude mark, as the web. */
96
+ provider: string;
97
+ /** Width and height in points. Default 14, as the web. */
98
+ size?: number;
99
+ /** Accessible name. Without it the mark is hidden from assistive tech (web `aria-hidden`). */
100
+ title?: string;
101
+ /** Set as `data-testid` on web, where `Svg` does not map `testID`. */
102
+ testID?: string;
103
+ style?: ViewProps["style"];
104
+ };
105
+
106
+ /**
107
+ * The brand mark of an AI provider, as the web `ProviderIcon`. The marks keep
108
+ * their brand colours; the OpenAI and Cursor marks switch with the scheme.
109
+ */
110
+ export function ProviderIcon({ provider, size = 14, title, testID, style }: ProviderIconProps) {
111
+ const scheme = useScheme();
112
+ const { viewBox, layers } = providerMark(provider);
113
+ const paint = (fill: SchemeFill) => (typeof fill === "string" ? fill : fill[scheme]);
114
+ return (
115
+ <Svg
116
+ width={size}
117
+ height={size}
118
+ viewBox={viewBox}
119
+ {...svgA11yProps({ title, testID }, Platform.OS)}
120
+ style={style}
121
+ >
122
+ {layers.map((layer, index) =>
123
+ "square" in layer ? (
124
+ <Rect key={index} width={layer.square} height={layer.square} fill={paint(layer.fill)} />
125
+ ) : (
126
+ <Path
127
+ key={index}
128
+ d={layer.d}
129
+ fill={paint(layer.fill)}
130
+ {...(layer.evenOdd ? { fillRule: "evenodd", clipRule: "evenodd" } : {})}
131
+ />
132
+ ),
133
+ )}
134
+ </Svg>
135
+ );
136
+ }
@@ -9,6 +9,8 @@ export { AppWordmark } from "./AppWordmark";
9
9
  export type { AppWordmarkProps, ProductAppName } from "./AppWordmark";
10
10
  export { GoogleMark } from "./GoogleMark";
11
11
  export type { GoogleMarkProps } from "./GoogleMark";
12
+ export { PROVIDER_MARKS, ProviderIcon, providerLabel, providerMark } from "./ProviderIcon";
13
+ export type { ProviderIconProps, ProviderIconProvider, ProviderMark } from "./ProviderIcon";
12
14
  export {
13
15
  APP_MARKS,
14
16
  APP_MARK_CROP_VIEWBOX,
@@ -1,5 +1,6 @@
1
1
  import { palette, type Palette } from "./palette";
2
2
  import { tokens, type Tokens } from "./tokens";
3
+ import { fontFamily, type ThemeFonts } from "./typography";
3
4
 
4
5
  /** `tokens` and `palette` value types with the literal hex widened to `string` and `readonly` dropped. */
5
6
  type Widen<T> = T extends string ? string : { -readonly [K in keyof T]: Widen<T[K]> };
@@ -15,6 +16,8 @@ export type ThemeConfig = {
15
16
  color?: Partial<ThemeColors>;
16
17
  /** Overrides for `palette` (primary, danger, overlay and the other web semantic colours). */
17
18
  palette?: Partial<ThemePalette>;
19
+ /** Overrides for `fontFamily`: the family name for each weight, as the app loads the fonts. */
20
+ font?: Partial<ThemeFonts>;
18
21
  };
19
22
 
20
23
  type ColorValue = string | { light: string; dark: string };
@@ -24,7 +27,7 @@ type ColorValue = string | { light: string; dark: string };
24
27
  * the existing pair object, so values that hold the pair (`floatingFill`,
25
28
  * `floatingHairline`, `floatingSolidFill`) follow it.
26
29
  */
27
- function assignColors(
30
+ function assignValues(
28
31
  target: { readonly [key: string]: ColorValue },
29
32
  source: { readonly [key: string]: ColorValue | undefined },
30
33
  ) {
@@ -36,25 +39,50 @@ function assignColors(
36
39
  }
37
40
  }
38
41
 
42
+ /** Bumped by each `configureTheme` call; `useScheme` redraws on it. */
43
+ let themeVersion = 0;
44
+ const themeListeners = new Set<() => void>();
45
+
46
+ /** Calls `listener` after each `configureTheme`. Returns the unsubscribe. */
47
+ export function subscribeTheme(listener: () => void): () => void {
48
+ themeListeners.add(listener);
49
+ return () => {
50
+ themeListeners.delete(listener);
51
+ };
52
+ }
53
+
54
+ /** The number of `configureTheme` calls so far, a snapshot for `useSyncExternalStore`. */
55
+ export function getThemeVersion(): number {
56
+ return themeVersion;
57
+ }
58
+
39
59
  /**
40
- * Sets the app's own colours. It writes into the shared `tokens.color` and
41
- * `palette` objects, which every component reads at render, so call it once at
42
- * app start, before the first render (for example at the top of the root
43
- * `_layout.tsx`, outside the component). A call after the first render does not
44
- * redraw mounted components. Each pair gives both schemes:
60
+ * Sets the app's own colours and fonts. It writes into the shared
61
+ * `tokens.color`, `palette` and `fontFamily` objects, which every component
62
+ * reads at render. Call it once at app start, before the first render (for
63
+ * example at the top of the root `_layout.tsx`, outside the component). A
64
+ * later call (for example a second dark palette the user picks) redraws the
65
+ * mounted components under `NativeUiProvider` that read the scheme (`useScheme`); app code that copies
66
+ * values out, or memoises on the scheme alone, must redraw on its own. Each
67
+ * pair gives both schemes:
45
68
  *
46
69
  * ```ts
47
70
  * configureTheme({
48
71
  * color: { background: { light: "#f4f5f6", dark: "#020202" } },
49
72
  * palette: { primary: { light: "#09090b", dark: "#fafafa" } },
73
+ * font: { regular: "Inter_400Regular", semibold: "Inter_600SemiBold" },
50
74
  * });
51
75
  * ```
52
76
  *
53
- * Only the JS colours change. NativeWind classes keep the app's Tailwind theme
54
- * (`tailwind-preset` reads the package values from source), and constants copied
55
- * out before the call (`NEUTRAL_500`) keep the package value.
77
+ * Only the JS values change. NativeWind classes keep the app's Tailwind theme
78
+ * (`tailwind-preset` reads the package values from source; pass the same
79
+ * family names to `nativeUiPreset({ font })`), and constants copied out before
80
+ * the call (`NEUTRAL_500`) keep the package value. The app loads the font files.
56
81
  */
57
82
  export function configureTheme(config: ThemeConfig): void {
58
- if (config.color) assignColors(tokens.color, config.color);
59
- if (config.palette) assignColors(palette, config.palette);
83
+ if (config.color) assignValues(tokens.color, config.color);
84
+ if (config.palette) assignValues(palette, config.palette);
85
+ if (config.font) assignValues(fontFamily, config.font);
86
+ themeVersion += 1;
87
+ for (const listener of themeListeners) listener();
60
88
  }
@@ -133,23 +133,26 @@ const TEXT_SIZE = {
133
133
 
134
134
  export type TextSize = keyof typeof TEXT_SIZE;
135
135
 
136
- /** Tailwind `leading-tight` and `leading-snug`, as multiples of the size. */
137
- const LEADING = { tight: 1.25, snug: 1.375 } as const;
136
+ /** Tailwind `leading-tight`, `leading-snug` and `leading-relaxed`, as multiples of the size. */
137
+ const LEADING = { tight: 1.25, snug: 1.375, relaxed: 1.625 } as const;
138
+
139
+ export type TextLeading = keyof typeof LEADING;
138
140
 
139
141
  /**
140
142
  * Font size and line height of a `text-*` class as a style, set beside the
141
143
  * class. On Expo web at 390 wide (0.3.6 gallery), `Button` labels with
142
144
  * `text-sm` measured 16, so the class alone does not always reach the text.
143
- * `"tight"` and `"snug"` are the Tailwind `leading-tight` (1.25 × the size)
144
- * and `leading-snug` (1.375 ×).
145
+ * `"tight"`, `"snug"` and `"relaxed"` are the Tailwind `leading-tight` (1.25 ×
146
+ * the size), `leading-snug` (1.375 ×) and `leading-relaxed` (1.625 ×, the web
147
+ * `.markdown` body and the settings descriptions).
145
148
  */
146
- export function sizeStyle(size: TextSize, leading?: "tight" | "snug") {
149
+ export function sizeStyle(size: TextSize, leading?: TextLeading) {
147
150
  const { fontSize, lineHeight } = TEXT_SIZE[size];
148
151
  return { fontSize, lineHeight: leading ? fontSize * LEADING[leading] : lineHeight };
149
152
  }
150
153
 
151
- /** Tailwind `tracking-tight`, in em. */
152
- const TRACKING = { tight: -0.025 } as const;
154
+ /** Tailwind `tracking-tight` and `tracking-wide`, in em. */
155
+ const TRACKING = { tight: -0.025, wide: 0.025 } as const;
153
156
 
154
157
  /**
155
158
  * Letter spacing of a `tracking-*` class at `fontSize`, as a style in points.
@@ -313,23 +316,26 @@ export function tabColors(variant: TabsVariant, selected: boolean, scheme: Schem
313
316
  export type TabsSize = "default" | "sm";
314
317
 
315
318
  /**
316
- * Trigger box and type for `TabsTrigger` and `PillTabs`. Without larger text
317
- * these are the web sizes: `pills` and `segmented` are the web `.t-tab` (30
318
- * high, `px-3 py-1`, `text-sm`), `soft` the web soft trigger (`px-4 py-2
319
- * text-sm`) and `sm` the web `sm` list (`px-2.5 py-1 text-xs`). With larger
320
- * text they are the 0.3.4 sizes. `minHeight` is a minimum, so a large Dynamic
321
- * Type size grows the trigger instead of clipping its label.
319
+ * Trigger box and type for `TabsTrigger` and `PillTabs`. Phone-tuned from the
320
+ * web look: `pills` and `segmented` are the web `.t-tab` (`py-1`, `text-sm`)
321
+ * grown to 36 high with `px-3.5`, so the 4 slop above and below makes 44;
322
+ * `sm` is the web `sm` list (`px-2.5 py-1 text-xs`) with the label kept at 14
323
+ * (`text-sm`, `px-3`) and also 36 high; `soft` is the web soft trigger
324
+ * (`px-4 py-2 text-sm`). With larger text they are the 0.3.4 sizes.
325
+ * `minHeight` is a minimum, so a large Dynamic Type size grows the trigger
326
+ * instead of clipping its label. `letterSpacing` is the web type-scale
327
+ * tracking of the label (`tokens/src/css/type-scale.css`): `text-sm` is 0em.
322
328
  */
323
329
  export function tabTriggerSize(variant: TabsVariant, size: TabsSize, largerText: boolean) {
324
330
  if (largerText) {
325
331
  return size === "sm"
326
- ? { minHeight: 48, className: "px-4 py-2", textClassName: "text-lg" }
327
- : { minHeight: 56, className: "px-4 py-3", textClassName: "text-xl" };
332
+ ? { minHeight: 48, className: "px-4 py-2", textClassName: "text-lg", letterSpacing: 0 }
333
+ : { minHeight: 56, className: "px-4 py-3", textClassName: "text-xl", letterSpacing: 0 };
328
334
  }
329
- if (size === "sm") return { minHeight: 30, className: "px-2.5 py-1", textClassName: "text-xs" };
335
+ if (size === "sm") return { minHeight: 36, className: "px-3 py-1", textClassName: "text-sm", letterSpacing: 0 };
330
336
  return variant === "soft"
331
- ? { minHeight: 36, className: "px-4 py-2", textClassName: "text-sm" }
332
- : { minHeight: 30, className: "px-3 py-1", textClassName: "text-sm" };
337
+ ? { minHeight: 36, className: "px-4 py-2", textClassName: "text-sm", letterSpacing: 0 }
338
+ : { minHeight: 36, className: "px-3.5 py-1", textClassName: "text-sm", letterSpacing: 0 };
333
339
  }
334
340
 
335
341
  /** The segmented track, web `--tabs-bar-bg` (`--default`). */
@@ -341,14 +347,14 @@ export function tabTrackStyle(scheme: Scheme) {
341
347
  * Fill and label of the `FloatingTabBar` active pill. On the web the raised
342
348
  * `--segment` pill sits on a muted `--default` track. The floating bar is
343
349
  * already the raised surface (`tokens.color.fill`, near-white in light), so in
344
- * light the pill takes the muted track fill (`palette.default`), as a white
345
- * `--segment` pill would not show on it. In dark it keeps `--segment`
346
- * (`palette.segment`), lighter than the dark glass. The label is the
350
+ * light the pill takes the muted track fill (`--default`), as a white
351
+ * `--segment` pill would not show on it. In dark it keeps the kit `--segment`,
352
+ * lighter than the dark glass. Both are `palette.tabBarPill`. The label is the
347
353
  * segmented `--tabs-text-active`, as `tabColors("segmented", true, scheme)`.
348
354
  */
349
355
  export function floatingTabPillColors(scheme: Scheme): TabColors {
350
356
  return {
351
- backgroundColor: scheme === "light" ? palette.default.light : palette.segment.dark,
357
+ backgroundColor: palette.tabBarPill[scheme],
352
358
  color: tabColors("segmented", true, scheme).color,
353
359
  };
354
360
  }
@@ -372,16 +378,26 @@ export function checkboxColors(checked: boolean, scheme: Scheme) {
372
378
  };
373
379
  }
374
380
 
381
+ export type SwitchSize = "sm" | "md";
382
+
375
383
  /**
376
- * Track and thumb size classes, and the track size in points for the hit slop.
377
- * Without larger text it is the web `Switch`: a `h-5 w-9` track (20 × 36,
378
- * with a 1 border) and a `size-4` thumb that moves `translate-x-4`. With larger
379
- * text it is the 0.3.4 size. `justify-center` on the track centres the thumb.
384
+ * Track and thumb size classes, the thumb offset off and on, and the track
385
+ * size in points for the hit slop. `justify-center` on the track centres the
386
+ * thumb. Without larger text:
387
+ * - `sm` is the web `Switch`: a `h-5 w-9` track (20 × 36, with a 1 border)
388
+ * and a `size-4` thumb that moves `translate-x-4`.
389
+ * - `md` is the larger app switch some web apps set through `UiConfig.Switch`:
390
+ * a `h-6 w-10` track (24 × 40, with a 1 border) and a `size-5` thumb that
391
+ * sits at `translate-x-0.5` and moves to `translate-x-[18px]`.
392
+ * With larger text both are the 0.3.4 size.
380
393
  */
381
- export function switchSize(largerText: boolean) {
382
- return largerText
383
- ? { track: "h-8 w-14", thumb: "h-7 w-7", on: "translate-x-6", width: 56, height: 32 }
384
- : { track: "h-5 w-9", thumb: "h-4 w-4", on: "translate-x-4", width: 36, height: 20 };
394
+ export function switchSize(largerText: boolean, size: SwitchSize = "sm") {
395
+ if (largerText) {
396
+ return { track: "h-8 w-14", thumb: "h-7 w-7", off: "translate-x-0", on: "translate-x-6", width: 56, height: 32 };
397
+ }
398
+ return size === "md"
399
+ ? { track: "h-6 w-10", thumb: "h-5 w-5", off: "translate-x-0.5", on: "translate-x-[18px]", width: 40, height: 24 }
400
+ : { track: "h-5 w-9", thumb: "h-4 w-4", off: "translate-x-0", on: "translate-x-4", width: 36, height: 20 };
385
401
  }
386
402
 
387
403
  /**
@@ -0,0 +1,4 @@
1
+ // `@vvedantb/native-ui/gesture-handler`. Imports `react-native-gesture-handler`
2
+ // (an optional peer): the only entry that does, so an app without it bundles
3
+ // the `PanResponder` sheets as before.
4
+ export { GestureHandlerProvider } from "../primitives/sheet-gesture";
@@ -0,0 +1,4 @@
1
+ // `@vvedantb/native-ui/keyboard-controller`. Imports
2
+ // `react-native-keyboard-controller` (an optional peer): the only entry that
3
+ // does, so an app without it keeps React Native's `KeyboardAvoidingView`.
4
+ export { KeyboardControllerProvider } from "../primitives/keyboard-controller";
@@ -2,4 +2,4 @@
2
2
  // `.markdown` block. Not on the root entry: it imports the optional peer
3
3
  // `react-native-marked`, and Metro does not tree-shake.
4
4
  export { Markdown, markdownStyles, inlineCodeStyle, MARKDOWN_SCALE, MARKDOWN_LIST } from "../markdown/Markdown";
5
- export type { MarkdownProps } from "../markdown/Markdown";
5
+ export type { MarkdownHeadings, MarkdownProps } from "../markdown/Markdown";