@vvedantb/native-ui 0.2.0 → 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 +88 -8
  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,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
+ ];
@@ -0,0 +1,360 @@
1
+ /**
2
+ * Colours for `Button`, `Input`, `Textarea`, the `Select` trigger, `Switch`,
3
+ * `Checkbox`, `Tabs`, `Card` and component text, taken from `palette` and
4
+ * `tokens` instead of NativeWind theme keys (`bg-primary`, `bg-input`,
5
+ * `text-foreground`) that apps do not define, or define without a dark value.
6
+ * Also text weights and sizes as styles, the `SearchInput` metrics, the
7
+ * compact window test for dialogs and the touch-target maths. No React Native import, so the tests can load
8
+ * this file.
9
+ */
10
+ import { palette, withAlpha } from "./palette";
11
+ import { tokens } from "./tokens";
12
+
13
+ type Scheme = "light" | "dark";
14
+
15
+ /**
16
+ * `primary` and `default` are the same (`default` is the web name). `outline`
17
+ * is the web bordered neutral button. `tinted` is the pink tint that `outline`
18
+ * drew before 0.3.0.
19
+ */
20
+ export type ButtonVariant =
21
+ | "primary"
22
+ | "default"
23
+ | "secondary"
24
+ | "outline"
25
+ | "destructive"
26
+ | "ghost"
27
+ | "link"
28
+ | "tinted";
29
+
30
+ export type ButtonColors = {
31
+ backgroundColor: string;
32
+ borderColor: string;
33
+ color: string;
34
+ };
35
+
36
+ /**
37
+ * Fill, border and label colour per variant, from the web classes
38
+ * (`bg-primary text-primary-foreground`, `bg-default text-default-foreground`
39
+ * and so on). `null` for `tinted`, which keeps its pink NativeWind classes.
40
+ */
41
+ export function buttonColors(variant: ButtonVariant, scheme: Scheme): ButtonColors | null {
42
+ const none = "transparent";
43
+ switch (variant) {
44
+ case "secondary":
45
+ return {
46
+ backgroundColor: palette.default[scheme],
47
+ borderColor: none,
48
+ color: palette.defaultForeground[scheme],
49
+ };
50
+ case "outline":
51
+ return {
52
+ backgroundColor: none,
53
+ borderColor: tokens.color.border[scheme],
54
+ color: tokens.color.foreground[scheme],
55
+ };
56
+ case "destructive":
57
+ return { backgroundColor: palette.destructive, borderColor: none, color: tokens.color.white };
58
+ case "ghost":
59
+ return { backgroundColor: none, borderColor: none, color: tokens.color.muted[scheme] };
60
+ case "link":
61
+ return { backgroundColor: none, borderColor: none, color: palette.primary[scheme] };
62
+ case "tinted":
63
+ return null;
64
+ default:
65
+ return {
66
+ backgroundColor: palette.primary[scheme],
67
+ borderColor: none,
68
+ color: palette.primaryForeground[scheme],
69
+ };
70
+ }
71
+ }
72
+
73
+ /**
74
+ * The web field look (`rounded-field border border-border bg-field-background`),
75
+ * radius 10 as on the web. A flat object: on web the `Select` trigger is a
76
+ * Radix slot, which spreads `style` and so drops arrays.
77
+ */
78
+ export function fieldStyle(scheme: Scheme) {
79
+ return {
80
+ backgroundColor: tokens.color.surface[scheme],
81
+ borderColor: tokens.color.border[scheme],
82
+ borderRadius: tokens.radius.field,
83
+ };
84
+ }
85
+
86
+ /** Text on `fieldStyle`. From the same scheme as the fill, so the two cannot disagree. */
87
+ export function fieldTextStyle(scheme: Scheme) {
88
+ return textStyle("foreground", scheme);
89
+ }
90
+
91
+ /**
92
+ * `foreground` and `muted` are the web `text-foreground` and
93
+ * `text-muted-foreground`; `overlayMuted` is the web `DialogDescription`.
94
+ */
95
+ export type TextTone = "foreground" | "muted" | "overlayMuted";
96
+
97
+ /**
98
+ * Text colour as a style. Where an app defines the NativeWind `foreground` key
99
+ * with a nested `dark` shade, `text-foreground` keeps the light value in dark
100
+ * mode, so class-coloured text was near-black on dark surfaces.
101
+ */
102
+ export function textStyle(tone: TextTone, scheme: Scheme) {
103
+ return {
104
+ color: tone === "overlayMuted" ? palette.overlayMuted[scheme] : tokens.color[tone][scheme],
105
+ };
106
+ }
107
+
108
+ /** The web `font-medium` and `font-semibold`. */
109
+ export type TypeWeight = "medium" | "semibold";
110
+
111
+ const FONT_WEIGHT = { medium: "500", semibold: "600" } as const;
112
+
113
+ /**
114
+ * Font weight as a style, set beside the `font-instrument-sans-*` class with
115
+ * the same weight. Expo web and many apps map every one of those classes to
116
+ * the one `Instrument Sans` family with no weight, so the class alone draws
117
+ * Regular. There is no `bold`: the web titles are semibold, and on Android 700
118
+ * can add a synthetic bold to a face that is already bold.
119
+ */
120
+ export function typeStyle(weight: TypeWeight) {
121
+ return { fontWeight: FONT_WEIGHT[weight] };
122
+ }
123
+
124
+ /** Tailwind `text-*` sizes with their default line heights, in points. */
125
+ const TEXT_SIZE = {
126
+ "text-xs": { fontSize: 12, lineHeight: 16 },
127
+ "text-sm": { fontSize: 14, lineHeight: 20 },
128
+ "text-base": { fontSize: 16, lineHeight: 24 },
129
+ "text-lg": { fontSize: 18, lineHeight: 28 },
130
+ "text-xl": { fontSize: 20, lineHeight: 28 },
131
+ "text-2xl": { fontSize: 24, lineHeight: 32 },
132
+ } as const;
133
+
134
+ export type TextSize = keyof typeof TEXT_SIZE;
135
+
136
+ /** Tailwind `leading-tight` and `leading-snug`, as multiples of the size. */
137
+ const LEADING = { tight: 1.25, snug: 1.375 } as const;
138
+
139
+ /**
140
+ * Font size and line height of a `text-*` class as a style, set beside the
141
+ * class. On Expo web at 390 wide (0.3.6 gallery), `Button` labels with
142
+ * `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
+ */
146
+ export function sizeStyle(size: TextSize, leading?: "tight" | "snug") {
147
+ const { fontSize, lineHeight } = TEXT_SIZE[size];
148
+ return { fontSize, lineHeight: leading ? fontSize * LEADING[leading] : lineHeight };
149
+ }
150
+
151
+ /**
152
+ * The web `COMPACT_OVERLAY_QUERY`, `(max-width: 639px), (max-height: 500px)`:
153
+ * phones, and short landscape windows such as 844 × 390.
154
+ */
155
+ export const COMPACT_OVERLAY = { maxWidth: 639, maxHeight: 500 } as const;
156
+
157
+ /** Whether a window of `width` × `height` points is compact: `DialogContent` is a bottom sheet there. */
158
+ export function isCompactOverlay(width: number, height: number) {
159
+ return width <= COMPACT_OVERLAY.maxWidth || height <= COMPACT_OVERLAY.maxHeight;
160
+ }
161
+
162
+ /**
163
+ * The web `Card` root (`rounded-lg bg-surface-card`): a flat fill at radius 16,
164
+ * with no border and no shadow. Also `SettingsSection`. A flat object, like
165
+ * `fieldStyle`.
166
+ */
167
+ export function cardStyle(scheme: Scheme) {
168
+ return {
169
+ backgroundColor: tokens.color.surfaceCard[scheme],
170
+ borderRadius: tokens.radius.lg,
171
+ };
172
+ }
173
+
174
+ /** The smallest touch target on a phone, in points (Apple's 44; Material asks 48). */
175
+ export const MIN_TOUCH_TARGET = 44;
176
+
177
+ /**
178
+ * `hitSlop` that makes a control painted `width` × `height` points reach
179
+ * `MIN_TOUCH_TARGET` on each axis, so it can keep the web size on screen. An
180
+ * axis that is already large enough gets 0.
181
+ */
182
+ export function touchSlop(width: number, height: number = width) {
183
+ const pad = (size: number) => Math.max(0, Math.ceil((MIN_TOUCH_TARGET - size) / 2));
184
+ const x = pad(width);
185
+ const y = pad(height);
186
+ return { top: y, bottom: y, left: x, right: x };
187
+ }
188
+
189
+ export type SearchInputSize = "sm" | "md";
190
+
191
+ /**
192
+ * Field, icon and clear-button metrics for `SearchInput`, in points. Without
193
+ * larger text these are the web sizes (`search-input.tsx`, `clear-input.tsx`):
194
+ * `sm` is `h-8 pl-8 text-sm` with the icon at `left-2`, `md` is `h-10 pl-9`
195
+ * (the phone `text-base`) with the icon at `left-3`. Both have a 16 search
196
+ * icon, the field `px-3.5` at the end, and the `h-6 w-6` clear button at
197
+ * `right-2` with a 14 cross, for which a filled field reserves `pr-9`.
198
+ * `height` locks the field at the web height, border included (the web
199
+ * `h-8` and `h-10` are border-box), so the line height of the `text-*` class
200
+ * cannot grow it. `SearchInput` scales it with the system font size
201
+ * (`scaledFieldHeight`), so a large Dynamic Type size still grows the field.
202
+ * The padding leaves room for the line inside the lock: `sm` pads 4 above and
203
+ * below and `md` 7, not the web 8, because a native field adds its padding to
204
+ * the line height (8 drew `md` 42 high on Expo web). With larger text both
205
+ * sizes are the larger `Input` (64 high, `text-xl`), with a minimum height
206
+ * and no lock.
207
+ */
208
+ export function searchInputSize(size: SearchInputSize, largerText: boolean) {
209
+ if (largerText) {
210
+ return {
211
+ minHeight: 64,
212
+ height: undefined,
213
+ paddingY: 16,
214
+ paddingLeft: 48,
215
+ paddingRight: 16,
216
+ filledPaddingRight: 48,
217
+ text: "text-xl",
218
+ icon: 24,
219
+ iconLeft: 16,
220
+ clear: 32,
221
+ clearIcon: 20,
222
+ clearRight: 12,
223
+ } as const;
224
+ }
225
+ const shared = {
226
+ paddingRight: 14,
227
+ filledPaddingRight: 36,
228
+ icon: 16,
229
+ clear: 24,
230
+ clearIcon: 14,
231
+ clearRight: 8,
232
+ } as const;
233
+ return size === "md"
234
+ ? { ...shared, minHeight: 40, height: 40, paddingY: 7, paddingLeft: 36, text: "text-base", iconLeft: 12 } as const
235
+ : { ...shared, minHeight: 32, height: 32, paddingY: 4, paddingLeft: 32, text: "text-sm", iconLeft: 8 } as const;
236
+ }
237
+
238
+ /**
239
+ * A locked field height at the system font scale (`useWindowDimensions().fontScale`):
240
+ * `height` up to a scale of 1, and `height` × the scale above it, as a web
241
+ * `rem` height grows with the browser text size. `undefined` (no lock) stays
242
+ * `undefined`.
243
+ */
244
+ export function scaledFieldHeight(height: number | undefined, fontScale: number) {
245
+ return height === undefined ? undefined : height * Math.max(1, fontScale);
246
+ }
247
+
248
+ export type TabsVariant = "pills" | "segmented" | "soft";
249
+
250
+ export type TabColors = {
251
+ backgroundColor: string;
252
+ color: string;
253
+ };
254
+
255
+ /**
256
+ * Fill and label colour of one tab, from the web tab variables
257
+ * (`tabs-pills.css`). Unselected: no fill, `--muted` label. Selected: a
258
+ * `--default` pill or a `--segment` segment with the `--segment-foreground`
259
+ * label (the neutral primary, not the accent). `soft` is the web soft trigger
260
+ * (`bg-surface-secondary text-foreground`). Fill and label come from one
261
+ * scheme, so a selected label cannot match its fill.
262
+ */
263
+ export function tabColors(variant: TabsVariant, selected: boolean, scheme: Scheme): TabColors {
264
+ if (!selected) return { backgroundColor: "transparent", color: tokens.color.muted[scheme] };
265
+ switch (variant) {
266
+ case "segmented":
267
+ return { backgroundColor: palette.segment[scheme], color: palette.segmentForeground[scheme] };
268
+ case "soft":
269
+ return {
270
+ backgroundColor: tokens.color.surfaceSecondary[scheme],
271
+ color: tokens.color.foreground[scheme],
272
+ };
273
+ default:
274
+ return { backgroundColor: palette.default[scheme], color: palette.segmentForeground[scheme] };
275
+ }
276
+ }
277
+
278
+ export type TabsSize = "default" | "sm";
279
+
280
+ /**
281
+ * Trigger box and type for `TabsTrigger` and `PillTabs`. Without larger text
282
+ * these are the web sizes: `pills` and `segmented` are the web `.t-tab` (30
283
+ * high, `px-3 py-1`, `text-sm`), `soft` the web soft trigger (`px-4 py-2
284
+ * text-sm`) and `sm` the web `sm` list (`px-2.5 py-1 text-xs`). With larger
285
+ * text they are the 0.3.4 sizes. `minHeight` is a minimum, so a large Dynamic
286
+ * Type size grows the trigger instead of clipping its label.
287
+ */
288
+ export function tabTriggerSize(variant: TabsVariant, size: TabsSize, largerText: boolean) {
289
+ if (largerText) {
290
+ return size === "sm"
291
+ ? { minHeight: 48, className: "px-4 py-2", textClassName: "text-lg" }
292
+ : { minHeight: 56, className: "px-4 py-3", textClassName: "text-xl" };
293
+ }
294
+ if (size === "sm") return { minHeight: 30, className: "px-2.5 py-1", textClassName: "text-xs" };
295
+ return variant === "soft"
296
+ ? { minHeight: 36, className: "px-4 py-2", textClassName: "text-sm" }
297
+ : { minHeight: 30, className: "px-3 py-1", textClassName: "text-sm" };
298
+ }
299
+
300
+ /** The segmented track, web `--tabs-bar-bg` (`--default`). */
301
+ export function tabTrackStyle(scheme: Scheme) {
302
+ return { backgroundColor: palette.default[scheme] };
303
+ }
304
+
305
+ /**
306
+ * Box and check colours, from the web `Checkbox` classes: `bg-field-background
307
+ * border-border`, and `bg-primary border-primary text-primary-foreground` when
308
+ * checked (also when indeterminate).
309
+ */
310
+ export function checkboxColors(checked: boolean, scheme: Scheme) {
311
+ return checked
312
+ ? {
313
+ backgroundColor: palette.primary[scheme],
314
+ borderColor: palette.primary[scheme],
315
+ color: palette.primaryForeground[scheme],
316
+ }
317
+ : {
318
+ backgroundColor: tokens.color.surface[scheme],
319
+ borderColor: tokens.color.border[scheme],
320
+ color: palette.primaryForeground[scheme],
321
+ };
322
+ }
323
+
324
+ /**
325
+ * Track and thumb size classes, and the track size in points for the hit slop.
326
+ * Without larger text it is the web `Switch`: a `h-5 w-9` track (20 × 36,
327
+ * with a 1 border) and a `size-4` thumb that moves `translate-x-4`. With larger
328
+ * text it is the 0.3.4 size. `justify-center` on the track centres the thumb.
329
+ */
330
+ export function switchSize(largerText: boolean) {
331
+ return largerText
332
+ ? { track: "h-8 w-14", thumb: "h-7 w-7", on: "translate-x-6", width: 56, height: 32 }
333
+ : { track: "h-5 w-9", thumb: "h-4 w-4", on: "translate-x-4", width: 36, height: 20 };
334
+ }
335
+
336
+ /**
337
+ * Track and thumb colours, from the web `Switch` classes. The light off thumb
338
+ * is white (web `bg-background`) so it shows on the grey track. Flat objects,
339
+ * for the same reason as `fieldStyle`.
340
+ */
341
+ export function switchColors(checked: boolean, scheme: Scheme) {
342
+ if (checked) {
343
+ return {
344
+ track: { backgroundColor: palette.primary[scheme], borderColor: palette.primary[scheme] },
345
+ thumb: { backgroundColor: palette.primaryForeground[scheme] },
346
+ };
347
+ }
348
+ return scheme === "dark"
349
+ ? {
350
+ track: {
351
+ backgroundColor: withAlpha(tokens.color.muted.dark, 0.4),
352
+ borderColor: withAlpha(tokens.color.muted.dark, 0.6),
353
+ },
354
+ thumb: { backgroundColor: tokens.color.foreground.dark },
355
+ }
356
+ : {
357
+ track: { backgroundColor: palette.default.light, borderColor: tokens.color.border.light },
358
+ thumb: { backgroundColor: tokens.color.surface.light },
359
+ };
360
+ }
@@ -0,0 +1,20 @@
1
+ import { createContext, useContext } from "react";
2
+
3
+ /**
4
+ * Where a `Button` sits in a dialog. The web sizes buttons in the compact
5
+ * dialog with descendant selectors, which React Native lacks, so `DialogContent`
6
+ * and `DialogFooter` pass this down instead. `compact`: inside the phone bottom
7
+ * sheet, where buttons are at least 44 high (web `[&_button]:min-h-11`).
8
+ * `stacked`: inside its footer too, where buttons fill the width (web
9
+ * `flex-col-reverse`).
10
+ */
11
+ export type DialogLayout = { compact: boolean; stacked: boolean };
12
+
13
+ export const DialogLayoutContext = createContext<DialogLayout>({
14
+ compact: false,
15
+ stacked: false,
16
+ });
17
+
18
+ export function useDialogLayout(): DialogLayout {
19
+ return useContext(DialogLayoutContext);
20
+ }
@@ -0,0 +1,3 @@
1
+ // `@vvedantb/native-ui/ambient`. Loads `expo-linear-gradient` only through its
2
+ // optional-peer loader; without it the background is solid.
3
+ export { AmbientBackground } from "../AmbientBackground";
@@ -0,0 +1,2 @@
1
+ // `@vvedantb/native-ui/bottom-sheet`. Imports `@gorhom/bottom-sheet`.
2
+ export { BottomSheetContainer } from "../BottomSheetContainer";
@@ -0,0 +1,2 @@
1
+ // `@vvedantb/native-ui/brand`: the family app marks and their pure data.
2
+ export * from "../brand";
@@ -0,0 +1,3 @@
1
+ // `@vvedantb/native-ui/button`.
2
+ export { Button } from "../Button";
3
+ export type { ButtonProps, ButtonSize, ButtonVariant } from "../Button";